chat history as plugin

This commit is contained in:
2026-02-17 01:49:42 +00:00
parent 3dc7383e11
commit 4cdd257735
16 changed files with 31 additions and 23 deletions
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useSessions } from '@/state/useSessions';
import { useSessions } from 'plugins/ChatHistory/client';
import type { ChatMessage, ServerMessage, TaskInfo } from './types';
const SAVE_DEBOUNCE_MS = 1000;
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useSettings } from '@/state/useSettings';
import { useVisibleOpenCodeModels } from '@/state/useModels';
import { useOpenCodeSessions } from '@/state/useOpenCodeSessions';
import { useOpenCodeSessions } from 'plugins/ChatHistory/client';
import type { ChatMessage, ServerMessage, TaskInfo } from './types';
type UseOpenCodeOptions = {
@@ -0,0 +1,122 @@
import { useRef, useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { useSessions } from './state/useSessions';
import type { ModelOption } from '@/state/useModels';
import { useOpenCodeSessions } from './state/useOpenCodeSessions';
import type { useClaude, Attachment } from 'plugins/Chat/client';
import { EmbeddableChat } from 'plugins/Chat/client';
import { useSlashCommands } from './state/useSlashCommands';
import { Card } from '@/components/Card';
import { SessionBar } from './SessionBar';
export type { Attachment };
type ChatPanelProps = {
chat: ReturnType<typeof useClaude>;
provider?: 'claude' | 'opencode';
availableModels?: ModelOption[];
onProviderChange?: (provider: 'claude' | 'opencode') => void;
};
export const ChatPanel = ({ chat, provider = 'claude', availableModels = [], onProviderChange }: ChatPanelProps) => {
const { isConnected, isGenerating, sessionId, setSelectedModel, sendPrompt } = chat;
const location = useLocation();
const navigate = useNavigate();
const [commandFeedback, setCommandFeedback] = useState<string | null>(null);
const [fullscreen, setFullscreen] = useState(false);
const initialSentRef = useRef(false);
const claudeSessions = useSessions();
const opencodeSessions = useOpenCodeSessions();
const { archiveSession, deleteSession } =
provider === 'claude'
? claudeSessions
: { archiveSession: undefined, deleteSession: opencodeSessions.deleteSession };
const sessions = provider === 'claude' ? claudeSessions.sessions : opencodeSessions.sessions;
const slashCommands = useSlashCommands({ sessionId });
const sessionTitle = sessionId ? sessions.find((s) => s.id === sessionId)?.title : undefined;
const listPath = '/chat';
// Capture prefill input from location.state (one-time, before first render completes)
const locationState = location.state as {
initialMessage?: string;
prefillInput?: string;
model?: string;
cwd?: { root?: string; path: string };
attachmentIds?: string[];
images?: { filename: string; dataUrl: string }[];
} | null;
const initialPrefill = useRef(locationState?.prefillInput ?? '');
const handleBeforeSend = async (text: string) => {
if (text.startsWith('/')) {
const result = await slashCommands.execute(text);
if (result.handled) {
setCommandFeedback(result.feedback);
return true;
}
}
setCommandFeedback(null);
return false;
};
// Auto-send initial message from Home launcher
useEffect(() => {
const state = location.state as typeof locationState;
if (!state || initialSentRef.current) return;
if (state.prefillInput) {
initialSentRef.current = true;
window.history.replaceState({}, '', location.pathname);
return;
}
if (!state.initialMessage || !isConnected) return;
initialSentRef.current = true;
if (state.model) setSelectedModel(state.model);
sendPrompt(state.initialMessage, state.attachmentIds, state.images, state.cwd);
// Clear the location state so refresh doesn't re-send
window.history.replaceState({}, '', location.pathname);
}, [isConnected, location.state]);
return (
<Card
className={`flex flex-col overflow-hidden transition-all duration-200 rounded-none border-0 md:rounded-xl md:border-2 ${
fullscreen ? 'fixed inset-0 m-auto z-50 w-[90vw] h-[calc(90vh-4.5rem)]' : 'h-full'
}`}
>
<SessionBar
listPath={listPath}
provider={provider}
sessionTitle={sessionTitle}
isConnected={isConnected}
isGenerating={isGenerating}
fullscreen={fullscreen}
onArchive={
archiveSession && sessionId
? async () => {
await archiveSession(sessionId);
navigate(listPath);
}
: undefined
}
onDelete={async () => {
if (!sessionId) return;
await deleteSession(sessionId);
navigate(listPath);
}}
onToggleFullscreen={() => setFullscreen((f) => !f)}
/>
<EmbeddableChat
chat={chat}
provider={provider}
availableModels={availableModels}
onProviderChange={onProviderChange}
onBeforeSend={handleBeforeSend}
commandFeedback={commandFeedback}
defaultInput={initialPrefill.current}
className="flex-1 min-h-0"
/>
</Card>
);
};
@@ -0,0 +1,105 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router';
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
import { useSessions } from './state/useSessions';
import { useOpenCodeSessions } from './state/useOpenCodeSessions';
type Filter = 'all' | 'claude' | 'opencode';
export const SessionList = () => {
const [filter, setFilter] = useState<Filter>('all');
const claude = useSessions();
const opencode = useOpenCodeSessions();
const merged = useMemo(
() => [...claude.sessions, ...opencode.sessions].sort((a, b) => b.createdAt - a.createdAt),
[claude.sessions, opencode.sessions],
);
const filtered = filter === 'all' ? merged : merged.filter((s) => s.provider === filter);
const handleDelete = (id: string, provider: 'claude' | 'opencode') => {
if (provider === 'claude') claude.deleteSession(id);
else opencode.deleteSession(id);
};
return (
<div className="flex flex-col h-full items-center p-4 md:p-6">
<Card className="w-full max-w-2xl flex flex-col gap-4 h-full p-4 md:p-6 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-duck-dark/80">Sessions</h2>
<Button asChild className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer gap-2">
<Link to="/chat/new">
<Plus className="h-4 w-4" />
New Chat
</Link>
</Button>
</div>
{/* Radio filter */}
<div className="flex items-center gap-1 rounded-lg bg-white/60 p-1">
{(['all', 'claude', 'opencode'] as const).map((value) => (
<button
key={value}
onClick={() => setFilter(value)}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors cursor-pointer ${
filter === value ? 'bg-white text-duck-dark shadow-sm' : 'text-duck-dark/70 hover:text-duck-dark/90'
}`}
>
{value === 'all' ? 'All' : value === 'claude' ? 'Claude' : 'OpenCode'}
</button>
))}
</div>
{/* Session list */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
{filtered.length === 0 && (
<div className="text-center py-16 text-duck-dark/30 text-sm">No sessions yet. Start a new chat!</div>
)}
{filtered.map((session) => (
<div
key={`${session.provider}-${session.id}`}
className="group flex items-center gap-3 rounded-lg border border-duck-dark/10 bg-white/80 hover:bg-white/90 transition-colors"
>
<Link
to={session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`}
className="flex-1 flex items-center gap-3 px-4 py-3 min-w-0"
>
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-duck-dark/80 truncate">{session.title}</div>
<div className="text-xs text-duck-dark/40">
{new Date(session.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
<span
className={`ml-2 text-xs font-medium ${
session.provider === 'claude' ? 'text-duck-teal' : 'text-duck-orange'
}`}
>
{session.provider === 'claude' ? 'Claude' : 'OpenCode'}
</span>
<span className="ml-2 font-mono text-duck-dark/25">{session.id.slice(0, 8)}</span>
</div>
</div>
</Link>
<button
onClick={() => handleDelete(session.id, session.provider)}
className="shrink-0 p-2 mr-2 text-duck-dark/20 hover:text-red-500 md:opacity-0 md:group-hover:opacity-100 transition-opacity cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</Card>
</div>
);
};
@@ -0,0 +1,64 @@
import { Link } from 'react-router';
import { ArrowLeft, Archive, Trash2, Maximize2, Minimize2 } from 'lucide-react';
type SessionBarProps = {
listPath: string;
provider: 'claude' | 'opencode';
sessionTitle: string | undefined;
isConnected: boolean;
isGenerating: boolean;
fullscreen: boolean;
onArchive: (() => void) | undefined;
onDelete: () => void;
onToggleFullscreen: () => void;
};
export const SessionBar = ({
listPath,
provider,
sessionTitle,
isConnected,
isGenerating,
fullscreen,
onArchive,
onDelete,
onToggleFullscreen,
}: SessionBarProps) => (
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 bg-white/60">
<div className="flex items-center gap-1">
<Link to={listPath} className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors">
<ArrowLeft className="h-4 w-4" />
</Link>
{provider === 'claude' && onArchive && (
<button
onClick={onArchive}
className="p-1 text-duck-dark/40 hover:text-duck-teal transition-colors cursor-pointer"
>
<Archive className="h-4 w-4" />
</button>
)}
<button onClick={onDelete} className="p-1 text-duck-dark/40 hover:text-red-500 transition-colors cursor-pointer">
<Trash2 className="h-4 w-4" />
</button>
</div>
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 truncate px-3">
{sessionTitle ?? 'New chat'}
</div>
<div className="flex items-center gap-2 text-xs text-duck-dark/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>
<button
onClick={onToggleFullscreen}
className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors cursor-pointer"
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
</div>
</div>
);
@@ -0,0 +1,90 @@
import { useMemo } from 'react';
import { Link } from 'react-router';
import { MessageSquare, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
import { Card } from '@/components/Card';
import { useSessions } from './state/useSessions';
import { useOpenCodeSessions } from './state/useOpenCodeSessions';
import { useUserState } from '@/state/useUserState';
export const ChatHistory = () => {
const [collapsed, setCollapsed] = useUserState('widget:chatHistory:collapsed', true);
const claude = useSessions();
const opencode = useOpenCodeSessions();
const sessions = useMemo(
() => [...claude.sessions, ...opencode.sessions].sort((a, b) => b.createdAt - a.createdAt),
[claude.sessions, opencode.sessions],
);
const handleDelete = (id: string, provider: 'claude' | 'opencode') => {
if (provider === 'claude') claude.deleteSession(id);
else opencode.deleteSession(id);
};
return (
<div className="w-full">
<Card className="overflow-hidden">
<div className={`flex items-center justify-between px-4 pt-3 ${collapsed ? 'pb-3' : 'pb-1'}`}>
<Link to="/chat" className="text-xs font-semibold text-duck-dark/60 uppercase tracking-wide hover:underline">
Chat History
</Link>
<button
onClick={() => setCollapsed((c) => !c)}
className="text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer transition-colors"
>
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</button>
</div>
{!collapsed && (
<>
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
{sessions.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No sessions yet</p>
) : (
<ul className="space-y-0.5">
{sessions.map((session) => (
<li
key={`${session.provider}-${session.id}`}
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group"
>
<Link
to={session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`}
className="flex items-center gap-2 flex-1 min-w-0"
>
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
<div className="min-w-0 flex-1">
<span className="text-sm text-duck-dark truncate block">{session.title}</span>
<span className="text-xs text-duck-dark/40 truncate block">
{new Date(session.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
<span
className={`ml-1.5 font-medium ${
session.provider === 'claude' ? 'text-duck-teal' : 'text-duck-orange'
}`}
>
{session.provider === 'claude' ? 'Claude' : 'OpenCode'}
</span>
</span>
</div>
</Link>
<button
onClick={() => handleDelete(session.id, session.provider)}
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" />
</button>
</li>
))}
</ul>
)}
</div>
</>
)}
</Card>
</div>
);
};
@@ -0,0 +1,7 @@
export { ChatHistory as Widget } from './Widget';
export { SessionList as Screen } from './Screen';
export { ChatPanel } from './ChatPanel';
export { SessionBar } from './SessionBar';
export { useSessions } from './state/useSessions';
export { useOpenCodeSessions } from './state/useOpenCodeSessions';
export { useSlashCommands, type SlashCommandResult } from './state/useSlashCommands';
@@ -0,0 +1,38 @@
import type { SessionEntry, ChatMessage } from 'plugins/Chat/client';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query';
export const useOpenCodeSessions = () => {
type RawSessionEntry = Omit<SessionEntry, 'provider'>;
const client = useClient();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const { data: sessions = [] } = useQuery<RawSessionEntry[], Error, SessionEntry[]>({
queryKey: ['OC_SESSIONS'],
enabled: isAuthenticated,
queryFn: () => client.get<RawSessionEntry[]>('/opencode/sessions'),
select: (data) => data.map((s) => ({ ...s, provider: 'opencode' as const })),
});
const getMessages = (sessionId: string) => client.get<ChatMessage[]>(`/opencode/sessions/${sessionId}/messages`);
const renameSession = async (sessionId: string | null, title: string) => {
if (!title) return;
if (!sessionId) return;
await client.put(`/opencode/sessions/${sessionId}`, { title: title.slice(0, 200) });
queryClient.setQueryData<SessionEntry[]>(
['OC_SESSIONS'],
(prev) => prev?.map((s) => (s.id === sessionId ? { ...s, title } : s)) ?? [],
);
};
const deleteSession = async (sessionId: string) => {
await client.delete(`/opencode/sessions/${sessionId}`);
queryClient.setQueryData<SessionEntry[]>(['OC_SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []);
};
return { sessions, getMessages, renameSession, deleteSession };
};
@@ -0,0 +1,53 @@
import type { SessionEntry, ChatMessage } from 'plugins/Chat/client';
import type { SlashCommandResult } from './useSlashCommands';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query';
export const useSessions = () => {
type RawSessionEntry = Omit<SessionEntry, 'provider'>;
const client = useClient();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const { data: sessions = [] } = useQuery<RawSessionEntry[], Error, SessionEntry[]>({
queryKey: ['SESSIONS'],
enabled: isAuthenticated,
queryFn: () => client.get<RawSessionEntry[]>('/sessions'),
select: (data) => data.map((s) => ({ ...s, provider: 'claude' as const })),
});
const getMessages = (sessionId: string) => client.get<ChatMessage[]>(`/sessions/${sessionId}/messages`);
const saveMessages = (sessionId: string, messages: ChatMessage[]) =>
client.put(`/sessions/${sessionId}/messages`, messages);
const renameSession = async (sessionId: string | null, args: string): Promise<SlashCommandResult> => {
if (!args) return { handled: true, feedback: 'Usage: /rename <new title>' };
if (!sessionId) return { handled: true, feedback: 'No active session to rename.' };
const title = args.slice(0, 200);
try {
await client.put(`/sessions/${sessionId}`, { title });
queryClient.setQueryData<SessionEntry[]>(
['SESSIONS'],
(prev) => prev?.map((s) => (s.id === sessionId ? { ...s, title } : s)) ?? [],
);
return { handled: true, feedback: `Session renamed to "${title}"` };
} catch {
return { handled: true, feedback: 'Failed to rename session.' };
}
};
const archiveSession = async (sessionId: string) => {
await client.post(`/sessions/${sessionId}/archive`);
queryClient.setQueryData<SessionEntry[]>(['SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []);
};
const deleteSession = async (sessionId: string) => {
await client.delete(`/sessions/${sessionId}`);
queryClient.setQueryData<SessionEntry[]>(['SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []);
};
return { sessions, getMessages, saveMessages, renameSession, archiveSession, deleteSession };
};
@@ -0,0 +1,29 @@
import { useSessions } from './useSessions';
export type SlashCommandResult = { handled: true; feedback: string } | { handled: false };
type UseSlashCommandsParams = {
sessionId: string | null;
};
export const useSlashCommands = ({ sessionId }: UseSlashCommandsParams) => {
const { renameSession } = useSessions();
const execute = async (input: string): Promise<SlashCommandResult> => {
const trimmed = input.trim();
if (!trimmed.startsWith('/')) return { handled: false };
const spaceIndex = trimmed.indexOf(' ');
const command = spaceIndex === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIndex);
const args = spaceIndex === -1 ? '' : trimmed.slice(spaceIndex + 1).trim();
switch (command) {
case 'rename':
return renameSession(sessionId, args);
default:
return { handled: false };
}
};
return { execute };
};
@@ -0,0 +1,7 @@
export { Widget, Screen, ChatPanel, SessionBar } from './client';
export const plugin = {
id: 'ChatHistory',
name: 'Chat History',
description: 'Session management for chat history',
};
+3 -1
View File
@@ -11,6 +11,8 @@
"./Terminal/client": "./Terminal/client/index.ts",
"./Terminal/server": "./Terminal/server/index.ts",
"./Chat": "./Chat/index.ts",
"./Chat/client": "./Chat/client/index.ts"
"./Chat/client": "./Chat/client/index.ts",
"./ChatHistory": "./ChatHistory/index.ts",
"./ChatHistory/client": "./ChatHistory/client/index.ts"
}
}