mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(x): rename, pin, and delete chats from the sidebar
- Hover kebab menu on sidebar chat rows with Pin / Rename / Delete - Rename: inline edit persisted via the existing sessions:setTitle; custom titles survive since auto-titling only fills empty titles - Pin: up to 3 chats float to the top of the list (localStorage) - Delete: confirm dialog, sessions:delete, closes any open tab
This commit is contained in:
parent
890a7864f4
commit
bf24fb85bb
2 changed files with 166 additions and 9 deletions
|
|
@ -6163,6 +6163,20 @@ function App() {
|
|||
onOpenApps={openAppsView}
|
||||
recentRuns={runs}
|
||||
onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
||||
onRenameRun={(rid, title) => {
|
||||
void window.ipc.invoke('sessions:setTitle', { sessionId: rid, title })
|
||||
.then(() => setRuns((prev) => prev.map((r) => (r.id === rid ? { ...r, title } : r))))
|
||||
.catch((err) => console.error('Failed to rename chat:', err))
|
||||
}}
|
||||
onDeleteRun={(rid) => {
|
||||
void window.ipc.invoke('sessions:delete', { sessionId: rid })
|
||||
.then(() => {
|
||||
setRuns((prev) => prev.filter((r) => r.id !== rid))
|
||||
const openTab = chatTabs.find((t) => t.runId === rid)
|
||||
if (openTab) closeChatTab(openTab.id)
|
||||
})
|
||||
.catch((err) => console.error('Failed to delete chat:', err))
|
||||
}}
|
||||
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
|
||||
onOpenEmail={(threadId) => openEmailView(threadId)}
|
||||
onOpenHome={() => void navigateToView({ type: 'home' })}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ import {
|
|||
Home,
|
||||
LayoutGrid,
|
||||
Mic,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Pin,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
Plug,
|
||||
LoaderIcon,
|
||||
Mail,
|
||||
|
|
@ -61,6 +65,12 @@ import {
|
|||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status"
|
||||
import { SettingsDialog } from "@/components/settings-dialog"
|
||||
|
|
@ -127,6 +137,8 @@ type ServiceEventType = z.infer<typeof ServiceEvent>
|
|||
|
||||
const MAX_SYNC_EVENTS = 1000
|
||||
const RUN_STALE_MS = 2 * 60 * 60 * 1000
|
||||
const PINNED_CHATS_STORAGE_KEY = 'x:pinned-chats'
|
||||
const MAX_PINNED_CHATS = 3
|
||||
|
||||
const SERVICE_LABELS: Record<string, string> = {
|
||||
gmail: "Syncing Gmail",
|
||||
|
|
@ -171,6 +183,10 @@ type SidebarContentPanelProps = {
|
|||
onOpenAgent?: (slug: string) => void
|
||||
recentRuns?: { id: string; title?: string; createdAt: string; modifiedAt?: string }[]
|
||||
onOpenRun?: (runId: string) => void
|
||||
/** Persist a custom chat title (sessions:setTitle) and refresh the runs list. */
|
||||
onRenameRun?: (runId: string, title: string) => void
|
||||
/** Delete the chat's session (sessions:delete) and refresh the runs list. */
|
||||
onDeleteRun?: (runId: string) => void
|
||||
onOpenChatHistory?: () => void
|
||||
onOpenEmail?: (threadId?: string) => void
|
||||
onOpenHome?: () => void
|
||||
|
|
@ -422,6 +438,8 @@ export function SidebarContentPanel({
|
|||
onOpenApps,
|
||||
recentRuns = [],
|
||||
onOpenRun,
|
||||
onRenameRun,
|
||||
onDeleteRun,
|
||||
onOpenChatHistory,
|
||||
onOpenEmail,
|
||||
onOpenHome,
|
||||
|
|
@ -548,16 +566,54 @@ export function SidebarContentPanel({
|
|||
.slice(0, 10)
|
||||
}, [tree])
|
||||
|
||||
// Chats: the 5 most recently modified chats, newest first.
|
||||
// Pinned chats: a per-machine UI preference, persisted in localStorage.
|
||||
const [pinnedChatIds, setPinnedChatIds] = useState<string[]>(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PINNED_CHATS_STORAGE_KEY)
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : []
|
||||
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const toggleChatPin = useCallback((chatId: string) => {
|
||||
const isPinned = pinnedChatIds.includes(chatId)
|
||||
if (!isPinned && pinnedChatIds.length >= MAX_PINNED_CHATS) {
|
||||
toast(`You can pin up to ${MAX_PINNED_CHATS} chats`, 'error')
|
||||
return
|
||||
}
|
||||
const next = isPinned ? pinnedChatIds.filter((id) => id !== chatId) : [...pinnedChatIds, chatId]
|
||||
try {
|
||||
window.localStorage.setItem(PINNED_CHATS_STORAGE_KEY, JSON.stringify(next))
|
||||
} catch { /* ignore */ }
|
||||
setPinnedChatIds(next)
|
||||
}, [pinnedChatIds])
|
||||
|
||||
// Chats: pinned first, then the most recently modified, 10 rows total.
|
||||
const recentChats = React.useMemo(() => {
|
||||
const chatRecency = (r: { createdAt: string; modifiedAt?: string }) => {
|
||||
const ms = new Date(r.modifiedAt ?? r.createdAt).getTime()
|
||||
return Number.isFinite(ms) ? ms : 0
|
||||
}
|
||||
return [...recentRuns]
|
||||
.sort((a, b) => chatRecency(b) - chatRecency(a))
|
||||
.slice(0, 10)
|
||||
}, [recentRuns])
|
||||
const sorted = [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a))
|
||||
const pinned = sorted.filter((r) => pinnedChatIds.includes(r.id))
|
||||
const rest = sorted.filter((r) => !pinnedChatIds.includes(r.id))
|
||||
return [...pinned, ...rest.slice(0, Math.max(0, 10 - pinned.length))]
|
||||
}, [recentRuns, pinnedChatIds])
|
||||
|
||||
// Chat pending delete confirmation, if any.
|
||||
const [deleteChatTarget, setDeleteChatTarget] = useState<{ id: string; title: string } | null>(null)
|
||||
|
||||
// Inline chat rename: which row is editing and its draft text.
|
||||
const [renamingChatId, setRenamingChatId] = useState<string | null>(null)
|
||||
const [renameDraft, setRenameDraft] = useState('')
|
||||
const commitChatRename = useCallback((chatId: string) => {
|
||||
const title = renameDraft.trim()
|
||||
const current = recentChats.find((c) => c.id === chatId)
|
||||
setRenamingChatId(null)
|
||||
if (!title || title === (current?.title ?? '')) return
|
||||
onRenameRun?.(chatId, title)
|
||||
}, [renameDraft, recentChats, onRenameRun])
|
||||
|
||||
// Workspace count for the Workspaces sublabel — top-level dir children of
|
||||
// knowledge/Workspace (matches WorkspaceView's root listing).
|
||||
|
|
@ -971,10 +1027,75 @@ export function SidebarContentPanel({
|
|||
<SidebarMenu>
|
||||
{recentChats.map((chat) => (
|
||||
<SidebarMenuItem key={chat.id}>
|
||||
<SidebarMenuButton onClick={() => onOpenRun?.(chat.id)}>
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{chat.title || '(Untitled chat)'}</span>
|
||||
</SidebarMenuButton>
|
||||
{renamingChatId === chat.id ? (
|
||||
<div className="flex h-8 items-center gap-2 rounded-md px-2">
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commitChatRename(chat.id)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setRenamingChatId(null)
|
||||
}
|
||||
}}
|
||||
onBlur={() => commitChatRename(chat.id)}
|
||||
className="h-6 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SidebarMenuButton onClick={() => onOpenRun?.(chat.id)} className={onRenameRun ? 'pr-7' : undefined}>
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{chat.title || '(Untitled chat)'}</span>
|
||||
{pinnedChatIds.includes(chat.id) && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70 transition-opacity group-hover/menu-item:opacity-0" />
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
{onRenameRun && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Chat options"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/menu-item:opacity-100 data-[state=open]:opacity-100"
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start">
|
||||
<DropdownMenuItem onClick={() => toggleChatPin(chat.id)}>
|
||||
<Pin className="mr-2 size-3.5" />
|
||||
{pinnedChatIds.includes(chat.id) ? 'Unpin' : 'Pin'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setRenameDraft(chat.title || '')
|
||||
setRenamingChatId(chat.id)
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-2 size-3.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
{onDeleteRun && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteChatTarget({ id: chat.id, title: chat.title || '(Untitled chat)' })}
|
||||
>
|
||||
<Trash2 className="mr-2 size-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{onOpenChatHistory && (
|
||||
|
|
@ -993,6 +1114,28 @@ export function SidebarContentPanel({
|
|||
)}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<AlertDialog open={!!deleteChatTarget} onOpenChange={(open) => { if (!open) setDeleteChatTarget(null) }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete chat?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
“{deleteChatTarget?.title}” and its full history will be permanently deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
if (deleteChatTarget) onDeleteRun?.(deleteChatTarget.id)
|
||||
setDeleteChatTarget(null)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SidebarContent>
|
||||
{/* Billing / upgrade CTA or Log in CTA */}
|
||||
{isRowboatConnected && billing ? (() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue