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}
|
onOpenApps={openAppsView}
|
||||||
recentRuns={runs}
|
recentRuns={runs}
|
||||||
onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
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' })}
|
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
|
||||||
onOpenEmail={(threadId) => openEmailView(threadId)}
|
onOpenEmail={(threadId) => openEmailView(threadId)}
|
||||||
onOpenHome={() => void navigateToView({ type: 'home' })}
|
onOpenHome={() => void navigateToView({ type: 'home' })}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,11 @@ import {
|
||||||
Home,
|
Home,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Mic,
|
Mic,
|
||||||
|
MoreVertical,
|
||||||
|
Pencil,
|
||||||
|
Pin,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
|
Trash2,
|
||||||
Plug,
|
Plug,
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
Mail,
|
Mail,
|
||||||
|
|
@ -61,6 +65,12 @@ import {
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status"
|
import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status"
|
||||||
import { SettingsDialog } from "@/components/settings-dialog"
|
import { SettingsDialog } from "@/components/settings-dialog"
|
||||||
|
|
@ -127,6 +137,8 @@ type ServiceEventType = z.infer<typeof ServiceEvent>
|
||||||
|
|
||||||
const MAX_SYNC_EVENTS = 1000
|
const MAX_SYNC_EVENTS = 1000
|
||||||
const RUN_STALE_MS = 2 * 60 * 60 * 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> = {
|
const SERVICE_LABELS: Record<string, string> = {
|
||||||
gmail: "Syncing Gmail",
|
gmail: "Syncing Gmail",
|
||||||
|
|
@ -171,6 +183,10 @@ type SidebarContentPanelProps = {
|
||||||
onOpenAgent?: (slug: string) => void
|
onOpenAgent?: (slug: string) => void
|
||||||
recentRuns?: { id: string; title?: string; createdAt: string; modifiedAt?: string }[]
|
recentRuns?: { id: string; title?: string; createdAt: string; modifiedAt?: string }[]
|
||||||
onOpenRun?: (runId: string) => void
|
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
|
onOpenChatHistory?: () => void
|
||||||
onOpenEmail?: (threadId?: string) => void
|
onOpenEmail?: (threadId?: string) => void
|
||||||
onOpenHome?: () => void
|
onOpenHome?: () => void
|
||||||
|
|
@ -422,6 +438,8 @@ export function SidebarContentPanel({
|
||||||
onOpenApps,
|
onOpenApps,
|
||||||
recentRuns = [],
|
recentRuns = [],
|
||||||
onOpenRun,
|
onOpenRun,
|
||||||
|
onRenameRun,
|
||||||
|
onDeleteRun,
|
||||||
onOpenChatHistory,
|
onOpenChatHistory,
|
||||||
onOpenEmail,
|
onOpenEmail,
|
||||||
onOpenHome,
|
onOpenHome,
|
||||||
|
|
@ -548,16 +566,54 @@ export function SidebarContentPanel({
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
}, [tree])
|
}, [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 recentChats = React.useMemo(() => {
|
||||||
const chatRecency = (r: { createdAt: string; modifiedAt?: string }) => {
|
const chatRecency = (r: { createdAt: string; modifiedAt?: string }) => {
|
||||||
const ms = new Date(r.modifiedAt ?? r.createdAt).getTime()
|
const ms = new Date(r.modifiedAt ?? r.createdAt).getTime()
|
||||||
return Number.isFinite(ms) ? ms : 0
|
return Number.isFinite(ms) ? ms : 0
|
||||||
}
|
}
|
||||||
return [...recentRuns]
|
const sorted = [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a))
|
||||||
.sort((a, b) => chatRecency(b) - chatRecency(a))
|
const pinned = sorted.filter((r) => pinnedChatIds.includes(r.id))
|
||||||
.slice(0, 10)
|
const rest = sorted.filter((r) => !pinnedChatIds.includes(r.id))
|
||||||
}, [recentRuns])
|
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
|
// Workspace count for the Workspaces sublabel — top-level dir children of
|
||||||
// knowledge/Workspace (matches WorkspaceView's root listing).
|
// knowledge/Workspace (matches WorkspaceView's root listing).
|
||||||
|
|
@ -971,10 +1027,75 @@ export function SidebarContentPanel({
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{recentChats.map((chat) => (
|
{recentChats.map((chat) => (
|
||||||
<SidebarMenuItem key={chat.id}>
|
<SidebarMenuItem key={chat.id}>
|
||||||
<SidebarMenuButton onClick={() => onOpenRun?.(chat.id)}>
|
{renamingChatId === chat.id ? (
|
||||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
<div className="flex h-8 items-center gap-2 rounded-md px-2">
|
||||||
<span className="flex-1 truncate">{chat.title || '(Untitled chat)'}</span>
|
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||||
</SidebarMenuButton>
|
<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>
|
</SidebarMenuItem>
|
||||||
))}
|
))}
|
||||||
{onOpenChatHistory && (
|
{onOpenChatHistory && (
|
||||||
|
|
@ -993,6 +1114,28 @@ export function SidebarContentPanel({
|
||||||
)}
|
)}
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</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>
|
</SidebarContent>
|
||||||
{/* Billing / upgrade CTA or Log in CTA */}
|
{/* Billing / upgrade CTA or Log in CTA */}
|
||||||
{isRowboatConnected && billing ? (() => {
|
{isRowboatConnected && billing ? (() => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue