feat(cli): conversation sidebar with list/create/switch/delete (TUI-008)
This commit is contained in:
37
packages/cli/src/tui/hooks/use-app-mode.ts
Normal file
37
packages/cli/src/tui/hooks/use-app-mode.ts
Normal 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 };
|
||||
}
|
||||
139
packages/cli/src/tui/hooks/use-conversations.ts
Normal file
139
packages/cli/src/tui/hooks/use-conversations.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user