feat(cli): conversation sidebar with list/create/switch/delete (TUI-008)

This commit is contained in:
2026-03-15 15:02:46 -05:00
parent 836652b868
commit b900e0625b
5 changed files with 353 additions and 6 deletions

View File

@@ -0,0 +1,37 @@
import { useState, useCallback } from 'react';
export type AppMode = 'chat' | 'sidebar' | 'search';
export interface UseAppModeReturn {
mode: AppMode;
setMode: (mode: AppMode) => void;
toggleSidebar: () => void;
sidebarOpen: boolean;
}
export function useAppMode(): UseAppModeReturn {
const [mode, setModeState] = useState<AppMode>('chat');
const [sidebarOpen, setSidebarOpen] = useState(false);
const setMode = useCallback((next: AppMode) => {
setModeState(next);
if (next === 'sidebar') {
setSidebarOpen(true);
}
}, []);
const toggleSidebar = useCallback(() => {
setSidebarOpen((prev) => {
if (prev) {
// Closing sidebar — return to chat
setModeState('chat');
return false;
}
// Opening sidebar — set mode to sidebar
setModeState('sidebar');
return true;
});
}, []);
return { mode, setMode, toggleSidebar, sidebarOpen };
}

View File

@@ -0,0 +1,139 @@
import { useState, useEffect, useRef, useCallback } from 'react';
export interface ConversationSummary {
id: string;
title: string | null;
archived: boolean;
createdAt: string;
updatedAt: string;
}
export interface UseConversationsOptions {
gatewayUrl: string;
sessionCookie?: string;
}
export interface UseConversationsReturn {
conversations: ConversationSummary[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
createConversation: (title?: string) => Promise<ConversationSummary | null>;
deleteConversation: (id: string) => Promise<boolean>;
renameConversation: (id: string, title: string) => Promise<boolean>;
}
export function useConversations(opts: UseConversationsOptions): UseConversationsReturn {
const { gatewayUrl, sessionCookie } = opts;
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const mountedRef = useRef(true);
const headers = useCallback((): Record<string, string> => {
const h: Record<string, string> = { 'Content-Type': 'application/json' };
if (sessionCookie) h['Cookie'] = sessionCookie;
return h;
}, [sessionCookie]);
const refresh = useCallback(async () => {
if (!mountedRef.current) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`${gatewayUrl}/api/conversations`, { headers: headers() });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as ConversationSummary[];
if (mountedRef.current) {
setConversations(data);
}
} catch (err) {
if (mountedRef.current) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (mountedRef.current) {
setLoading(false);
}
}
}, [gatewayUrl, headers]);
useEffect(() => {
mountedRef.current = true;
void refresh();
return () => {
mountedRef.current = false;
};
}, [refresh]);
const createConversation = useCallback(
async (title?: string): Promise<ConversationSummary | null> => {
try {
const res = await fetch(`${gatewayUrl}/api/conversations`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ title: title ?? null }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as ConversationSummary;
if (mountedRef.current) {
setConversations((prev) => [data, ...prev]);
}
return data;
} catch {
return null;
}
},
[gatewayUrl, headers],
);
const deleteConversation = useCallback(
async (id: string): Promise<boolean> => {
try {
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
method: 'DELETE',
headers: headers(),
});
if (!res.ok) return false;
if (mountedRef.current) {
setConversations((prev) => prev.filter((c) => c.id !== id));
}
return true;
} catch {
return false;
}
},
[gatewayUrl, headers],
);
const renameConversation = useCallback(
async (id: string, title: string): Promise<boolean> => {
try {
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
method: 'PATCH',
headers: headers(),
body: JSON.stringify({ title }),
});
if (!res.ok) return false;
if (mountedRef.current) {
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)));
}
return true;
} catch {
return false;
}
},
[gatewayUrl, headers],
);
return {
conversations,
loading,
error,
refresh,
createConversation,
deleteConversation,
renameConversation,
};
}

View File

@@ -59,6 +59,8 @@ export interface UseSocketReturn {
availableThinkingLevels: string[];
sendMessage: (content: string) => void;
setThinkingLevel: (level: string) => void;
switchConversation: (id: string) => void;
clearMessages: () => void;
connectionError: string | null;
}
@@ -239,6 +241,22 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
});
}, []);
const clearMessages = useCallback(() => {
setMessages([]);
setCurrentStreamText('');
setCurrentThinkingText('');
setActiveToolCalls([]);
setIsStreaming(false);
}, []);
const switchConversation = useCallback(
(id: string) => {
clearMessages();
setConversationId(id);
},
[clearMessages],
);
return {
connected,
connecting,
@@ -255,6 +273,8 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
availableThinkingLevels,
sendMessage,
setThinkingLevel,
switchConversation,
clearMessages,
connectionError,
};
}