chat history in projects

This commit is contained in:
2026-02-23 06:49:18 +00:00
parent 46c3b6b71d
commit 3e2c8090d5
8 changed files with 212 additions and 30 deletions
@@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from 'react';
import { MessageSquare, History, Plus } from 'lucide-react';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import { useWorkspace } from '../../components/Workspace/WorkspaceContext';
import { useChatSessions } from './useChatSessions';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { getProviderDisplayName } from 'state/useModels';
type ChatSessionSelection = {
sessionId: string | null;
model?: string | null;
};
function formatModel(model: string): string {
if (!model.includes('/')) return model;
const [provider, modelId] = model.split('/') as [string, string];
if (provider.startsWith('officer-local-')) {
return `${getProviderDisplayName(provider)} - ${modelId}`;
}
return model.replace('/', ' - ');
}
export const ChatHeader = () => {
const { cwd, root } = useWorkspace();
const scoped = cwd !== '~';
const { sessions } = useChatSessions(scoped ? { cwd, cwdRoot: root } : {});
const [selection, setSelection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
const [activeSessionId] = usePanelChannel<string | null>('chat:active-session', null);
const [open, setOpen] = useState(false);
const autoResumedRef = useRef(false);
// Auto-resume the latest session on mount
useEffect(() => {
if (autoResumedRef.current || selection) return;
if (sessions.length > 0) {
const latest = sessions[0]!;
setSelection({ sessionId: latest.id, model: latest.model ?? null });
autoResumedRef.current = true;
}
}, [sessions, selection]);
const selectSession = (sessionId: string | null, model?: string | null) => {
setSelection({ sessionId, model });
setOpen(false);
};
const activeInList = activeSessionId ? sessions.some((s) => s.id === activeSessionId) : false;
return (
<>
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium truncate flex-1">Chat</span>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
title="Session history"
>
<History className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-0 max-h-80 flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="text-xs font-medium">Sessions</span>
<button
type="button"
onClick={() => selectSession(null)}
className="flex items-center gap-1 text-xs text-duck-teal hover:text-duck-teal/80 cursor-pointer"
>
<Plus className="h-3 w-3" />
New
</button>
</div>
<div className="flex-1 overflow-y-auto">
{/* Show active session at top if not yet in fetched list */}
{activeSessionId && !activeInList && (
<button
type="button"
onClick={() => selectSession(activeSessionId)}
className="w-full text-left px-3 py-2 bg-duck-teal/10 border-b border-border/50 cursor-pointer"
>
<div className="text-xs font-medium truncate text-duck-teal">Current session</div>
<div className="text-[10px] text-muted-foreground font-mono">{activeSessionId.slice(0, 8)}</div>
</button>
)}
{sessions.length === 0 && !activeSessionId ? (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">No sessions yet</div>
) : (
sessions.map((session) => {
const isActive = session.id === activeSessionId;
return (
<button
key={session.id}
type="button"
onClick={() => selectSession(session.id, session.model)}
className={`w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer border-b border-border/50 last:border-0 ${isActive ? 'bg-duck-teal/10' : ''}`}
>
<div className={`text-xs font-medium truncate ${isActive ? 'text-duck-teal' : ''}`}>
{session.title}
</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span>
{new Date(session.updatedAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
{session.model && (
<>
<span className="text-muted-foreground/40">|</span>
<span className="truncate text-duck-teal/60">{formatModel(session.model)}</span>
</>
)}
</div>
</button>
);
})
)}
</div>
</PopoverContent>
</Popover>
</>
);
};
@@ -1,17 +1,44 @@
import { useEffect } from 'react';
import { useWorkspace } from '../../components/Workspace';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { usePiChat } from '../../hooks/usePiChat';
import { EmbeddableChat } from './EmbeddableChat';
type ChatSessionSelection = {
sessionId: string | null;
model?: string | null;
};
export const ChatPanelWrapper = () => {
const { cwd, root } = useWorkspace();
const scoped = cwd !== '~';
const hostRoot = root === '~' || root === 'officer.dev';
const sandboxed = !hostRoot;
const [selection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
const [, setActiveSession] = usePanelChannel<string | null>('chat:active-session', null);
const cwdParam = scoped ? { root, path: cwd } : undefined;
const sessionId = selection?.sessionId ?? undefined;
const model = selection?.model ?? undefined;
const chat = usePiChat(sessionId, model, { replaceUrl: false });
useEffect(() => {
setActiveSession(chat.sessionId);
}, [chat.sessionId]);
return (
<EmbeddableChat
key={sessionId ?? 'new'}
className="h-full"
cwd={scoped ? { root, path: cwd } : undefined}
chat={chat}
sessionId={sessionId}
initialModel={model}
cwd={cwdParam}
sandboxed={sandboxed}
replaceUrl={false}
/>
);
};
@@ -1,6 +1,7 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { MessageSquare } from 'lucide-react';
import { ChatPanelWrapper } from './ChatPanelWrapper';
import { ChatHeader } from './ChatHeader';
export { MessageList } from './components/MessageList';
export { MessageBubble, StreamingBubble } from './components/MessageBubble';
@@ -29,5 +30,6 @@ export const appRegistryMetas: AppRegistryMeta[] = [
name: 'Chat',
icon: MessageSquare,
component: ChatPanelWrapper,
header: ChatHeader,
},
];
@@ -5,23 +5,30 @@ import { useQuery } from '@tanstack/react-query';
type UseChatSessionsParams = {
cwd?: string;
cwdRoot?: string;
};
export function useChatSessions({ cwd }: UseChatSessionsParams = {}) {
export function useChatSessions({ cwd, cwdRoot }: UseChatSessionsParams = {}) {
const client = useClient();
const { isAuthenticated } = useAuth();
const { data: sessions = [], isLoading } = useQuery<SessionEntry[]>({
queryKey: ['PI_SESSIONS', cwd],
queryKey: ['PI_SESSIONS', cwd, cwdRoot],
enabled: isAuthenticated,
queryFn: async () => {
const result = await client.post<{ sessions: SessionEntry[] }>('/pi/sessions', cwd ? { cwd } : {});
const body: Record<string, string> = {};
if (cwd) body.cwd = cwd;
if (cwdRoot) body.cwdRoot = cwdRoot;
const result = await client.post<{ sessions: SessionEntry[] }>('/pi/sessions', body);
return result.sessions;
},
});
function searchSessions(query: string) {
return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?q=${encodeURIComponent(query)}`);
const params = new URLSearchParams({ q: query });
if (cwd) params.set('cwd', cwd);
if (cwdRoot) params.set('cwdRoot', cwdRoot);
return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?${params}`);
}
return {
@@ -41,6 +41,11 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
}
}, [initialSessionId, initialModel, settings]);
// Sync selectedModel when initialModel changes (e.g. resuming a session)
useEffect(() => {
if (initialModel) setSelectedModel(initialModel);
}, [initialModel]);
const streamingRef = useRef('');
const rafRef = useRef<number | null>(null);
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);