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

@@ -6,9 +6,15 @@ export interface InputBarProps {
onSubmit: (value: string) => void; onSubmit: (value: string) => void;
isStreaming: boolean; isStreaming: boolean;
connected: boolean; connected: boolean;
placeholder?: string;
} }
export function InputBar({ onSubmit, isStreaming, connected }: InputBarProps) { export function InputBar({
onSubmit,
isStreaming,
connected,
placeholder: placeholderOverride,
}: InputBarProps) {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const handleSubmit = useCallback( const handleSubmit = useCallback(
@@ -20,11 +26,13 @@ export function InputBar({ onSubmit, isStreaming, connected }: InputBarProps) {
[onSubmit, isStreaming, connected], [onSubmit, isStreaming, connected],
); );
const placeholder = !connected const placeholder =
? 'disconnected — waiting for gateway…' placeholderOverride ??
: isStreaming (!connected
? 'waiting for response…' ? 'disconnected — waiting for gateway…'
: 'message mosaic…'; : isStreaming
? 'waiting for response…'
: 'message mosaic…');
return ( return (
<Box paddingX={1} borderStyle="single" borderColor="gray"> <Box paddingX={1} borderStyle="single" borderColor="gray">

View File

@@ -0,0 +1,143 @@
import React from 'react';
import { Box, Text, useInput } from 'ink';
import type { ConversationSummary } from '../hooks/use-conversations.js';
export interface SidebarProps {
conversations: ConversationSummary[];
activeConversationId: string | undefined;
selectedIndex: number;
onSelectIndex: (index: number) => void;
onSwitchConversation: (id: string) => void;
onDeleteConversation: (id: string) => void;
loading: boolean;
focused: boolean;
width: number;
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
if (diffDays < 7) {
return `${diffDays}d ago`;
}
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
const mon = months[date.getMonth()];
const dd = String(date.getDate()).padStart(2, '0');
return `${mon} ${dd}`;
}
function truncate(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen - 1) + '…';
}
export function Sidebar({
conversations,
activeConversationId,
selectedIndex,
onSelectIndex,
onSwitchConversation,
onDeleteConversation,
loading,
focused,
width,
}: SidebarProps) {
useInput(
(_input, key) => {
if (key.upArrow) {
onSelectIndex(Math.max(0, selectedIndex - 1));
}
if (key.downArrow) {
onSelectIndex(Math.min(conversations.length - 1, selectedIndex + 1));
}
if (key.return) {
const conv = conversations[selectedIndex];
if (conv) {
onSwitchConversation(conv.id);
}
}
if (_input === 'd') {
const conv = conversations[selectedIndex];
if (conv) {
onDeleteConversation(conv.id);
}
}
},
{ isActive: focused },
);
const borderColor = focused ? 'cyan' : 'gray';
// Available width for content inside border + padding
const innerWidth = width - 4; // 2 border + 2 padding
return (
<Box
flexDirection="column"
width={width}
borderStyle="single"
borderColor={borderColor}
paddingX={1}
>
<Text bold color="cyan">
Conversations
</Text>
<Box marginTop={0} flexDirection="column" flexGrow={1}>
{loading && conversations.length === 0 ? (
<Text dimColor>Loading</Text>
) : conversations.length === 0 ? (
<Text dimColor>No conversations</Text>
) : (
conversations.map((conv, idx) => {
const isActive = conv.id === activeConversationId;
const isSelected = idx === selectedIndex && focused;
const marker = isActive ? '● ' : ' ';
const time = formatRelativeTime(conv.updatedAt);
const title = conv.title ?? 'Untitled';
// marker(2) + title + space(1) + time
const maxTitleLen = Math.max(4, innerWidth - marker.length - time.length - 1);
const displayTitle = truncate(title, maxTitleLen);
return (
<Box key={conv.id}>
<Text
inverse={isSelected}
color={isActive ? 'cyan' : undefined}
dimColor={!isActive && !isSelected}
>
{marker}
{displayTitle}
{' '.repeat(
Math.max(0, innerWidth - marker.length - displayTitle.length - time.length),
)}
{time}
</Text>
</Box>
);
})
)}
</Box>
{focused && <Text dimColor> navigate enter switch d delete</Text>}
</Box>
);
}

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[]; availableThinkingLevels: string[];
sendMessage: (content: string) => void; sendMessage: (content: string) => void;
setThinkingLevel: (level: string) => void; setThinkingLevel: (level: string) => void;
switchConversation: (id: string) => void;
clearMessages: () => void;
connectionError: string | null; 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 { return {
connected, connected,
connecting, connecting,
@@ -255,6 +273,8 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
availableThinkingLevels, availableThinkingLevels,
sendMessage, sendMessage,
setThinkingLevel, setThinkingLevel,
switchConversation,
clearMessages,
connectionError, connectionError,
}; };
} }