diff --git a/desktop/src/features/chats/lib/chatPinStorage.ts b/desktop/src/features/chats/lib/chatPinStorage.ts new file mode 100644 index 000000000..28f865c98 --- /dev/null +++ b/desktop/src/features/chats/lib/chatPinStorage.ts @@ -0,0 +1,81 @@ +import * as React from "react"; + +// Workspace-scoped pinned chat ids. Local convenience state (mirrors +// chatProjectStorage): pins order the sidebar only, so they never need to +// sync across devices. +const STORAGE_PREFIX = "buzz:chat-pins:v1"; +const STORAGE_EVENT = "buzz:chat-pins-changed"; + +function storageKey(workspaceId: string | null | undefined) { + return `${STORAGE_PREFIX}:${workspaceId ?? "default"}`; +} + +function readStoredChatPins(workspaceId: string | null | undefined): string[] { + if (typeof window === "undefined") { + return []; + } + try { + const raw = window.localStorage.getItem(storageKey(workspaceId)); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.filter((value): value is string => typeof value === "string") + : []; + } catch { + return []; + } +} + +function writeStoredChatPins( + workspaceId: string | null | undefined, + chatIds: string[], +) { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.setItem( + storageKey(workspaceId), + JSON.stringify(chatIds), + ); + window.dispatchEvent(new CustomEvent(STORAGE_EVENT)); + } catch { + // Pins are a convenience layer; ignore unavailable storage. + } +} + +export function toggleStoredChatPin( + workspaceId: string | null | undefined, + chatId: string, +) { + const pins = readStoredChatPins(workspaceId); + writeStoredChatPins( + workspaceId, + pins.includes(chatId) + ? pins.filter((id) => id !== chatId) + : [chatId, ...pins], + ); +} + +export function useStoredChatPins( + workspaceId: string | null | undefined, +): ReadonlySet { + const [pins, setPins] = React.useState(() => + readStoredChatPins(workspaceId), + ); + + React.useEffect(() => { + const refresh = () => setPins(readStoredChatPins(workspaceId)); + refresh(); + window.addEventListener(STORAGE_EVENT, refresh); + window.addEventListener("storage", refresh); + return () => { + window.removeEventListener(STORAGE_EVENT, refresh); + window.removeEventListener("storage", refresh); + }; + }, [workspaceId]); + + return React.useMemo(() => new Set(pins), [pins]); +} diff --git a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx index 3ec0dcfbe..04c8550cc 100644 --- a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx +++ b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx @@ -93,6 +93,7 @@ export function ChatActivityTranscript({ blocks, identityPubkey, profiles, + showAgentIdentity = true, }: { /** Turn ids currently live in this channel — drives per-turn rendering. */ activeTurnIds?: ReadonlySet; @@ -100,6 +101,8 @@ export function ChatActivityTranscript({ blocks: ChatActivityRenderBlock[]; identityPubkey?: string; profiles?: UserProfileLookup; + /** Hidden in solo chats so agent replies read as part of the stream. */ + showAgentIdentity?: boolean; }) { if (blocks.length === 0) { return null; @@ -118,6 +121,7 @@ export function ChatActivityTranscript({ } key={renderBlock.id} profiles={profiles} + showAgentIdentity={showAgentIdentity} suppressPromptMessage={renderBlock.suppressPromptMessage} /> ))} @@ -131,6 +135,7 @@ function ChatActivityBlockView({ identityPubkey, isTurnActive, profiles, + showAgentIdentity, suppressPromptMessage, }: { agent: ManagedAgent | null; @@ -139,6 +144,7 @@ function ChatActivityBlockView({ /** Whether THIS block's turn is live (per-turn, never channel-wide). */ isTurnActive: boolean; profiles?: UserProfileLookup; + showAgentIdentity: boolean; suppressPromptMessage: boolean; }) { if (block.kind === "single") { @@ -148,6 +154,7 @@ function ChatActivityBlockView({ identityPubkey={identityPubkey} item={block.item} profiles={profiles} + showAgentIdentity={showAgentIdentity} suppressPromptMessage={suppressPromptMessage} /> ); @@ -160,6 +167,7 @@ function ChatActivityBlockView({ block={block} identityPubkey={identityPubkey} profiles={profiles} + showAgentIdentity={showAgentIdentity} suppressPromptMessage={suppressPromptMessage} /> ); @@ -174,6 +182,7 @@ function ChatActivityBlockView({ key={getSegmentKey(block.turnId, segment)} profiles={profiles} segment={segment} + showAgentIdentity={showAgentIdentity} suppressPromptMessage={suppressPromptMessage} /> ))} @@ -194,12 +203,14 @@ function CompletedTurnView({ block, identityPubkey, profiles, + showAgentIdentity, suppressPromptMessage, }: { agent: ManagedAgent | null; block: Extract; identityPubkey?: string; profiles?: UserProfileLookup; + showAgentIdentity: boolean; suppressPromptMessage: boolean; }) { const promptSegments = block.segments.filter( @@ -232,6 +243,7 @@ function CompletedTurnView({ item={item} key={item.id} profiles={profiles} + showAgentIdentity={showAgentIdentity} /> ))} @@ -256,12 +268,14 @@ function ChatActivitySegmentView({ identityPubkey, profiles, segment, + showAgentIdentity, suppressPromptMessage, }: { agent: ManagedAgent | null; identityPubkey?: string; profiles?: UserProfileLookup; segment: TranscriptTurnSegment; + showAgentIdentity: boolean; suppressPromptMessage: boolean; }) { if (segment.kind === "prompt") { @@ -306,6 +320,7 @@ function ChatActivitySegmentView({ identityPubkey={identityPubkey} item={segment.item} profiles={profiles} + showAgentIdentity={showAgentIdentity} suppressPromptMessage={suppressPromptMessage} /> ); @@ -316,12 +331,14 @@ function ChatActivityItemView({ identityPubkey, item, profiles, + showAgentIdentity, suppressPromptMessage, }: { agent: ManagedAgent | null; identityPubkey?: string; item: TranscriptItem; profiles?: UserProfileLookup; + showAgentIdentity: boolean; suppressPromptMessage: boolean; }) { if (item.type === "message") { @@ -338,6 +355,7 @@ function ChatActivityItemView({ identityPubkey={identityPubkey} item={item} profiles={profiles} + showAgentIdentity={showAgentIdentity} /> ); } @@ -418,11 +436,13 @@ function ChatTranscriptMessageRow({ identityPubkey, item, profiles, + showAgentIdentity = true, }: { agent: ManagedAgent | null; identityPubkey?: string; item: Extract; profiles?: UserProfileLookup; + showAgentIdentity?: boolean; }) { const isUser = item.role === "user"; const pubkey = isUser @@ -447,20 +467,26 @@ function ChatTranscriptMessageRow({ const displayText = cleanChatMessageText(item); const entrance = hasRecentEntrance(item.timestamp); + const hideIdentity = !isUser && !showAgentIdentity; + return ( - {!isUser ? ( + {!isUser && !hideIdentity ? ( ) : null} - - {isUser ? "You" : label} - + {!hideIdentity ? ( + + + {isUser ? "You" : label} + + + ) : null} {isUser ? ( - {!isOwn ? ( + {!isOwn && !hideIdentity ? ( - - - {isOwn ? "You" : displayName} - - + {!hideIdentity ? ( + + + {isOwn ? "You" : displayName} + + + ) : null} {isAgent ? ( 0; + // Solo chats (you + one agent) read as a plain stream: agent rows drop + // their avatar and name. Identities come back as soon as another agent or + // person participates, so multi-party chats stay attributable. + const showAgentIdentity = React.useMemo(() => { + const others = new Set(); + for (const message of messages) { + if (message.kind === KIND_SYSTEM_MESSAGE) { + continue; + } + const pubkey = normalizePubkey(message.pubkey); + if (identityPubkey && pubkey === normalizePubkey(identityPubkey)) { + continue; + } + others.add(pubkey); + } + if (defaultAgent?.pubkey) { + others.add(normalizePubkey(defaultAgent.pubkey)); + } + return others.size > 1; + }, [defaultAgent?.pubkey, identityPubkey, messages]); + // Auto-title: upgrade a still-default title (the first message, verbatim) // to a succinct subject line. Prefers the agent-generated `chat_title` // observer frame — the harness titles the conversation with a real model — @@ -507,6 +528,7 @@ export function ChatDetail({ isAgent={isAgentMessage} isOwn={isOwnMessage} profiles={profiles} + showAgentIdentity={showAgentIdentity} /> )} @@ -519,6 +541,7 @@ export function ChatDetail({ blocks={activityBlocks} identityPubkey={identityPubkey} activeTurnIds={activeTurnIds} + showAgentIdentity={showAgentIdentity} profiles={profiles} /> @@ -546,6 +569,7 @@ export function ChatDetail({ identityPubkey={identityPubkey} activeTurnIds={activeTurnIds} profiles={profiles} + showAgentIdentity={showAgentIdentity} /> ) : null} diff --git a/desktop/src/features/chats/ui/ChatListItem.tsx b/desktop/src/features/chats/ui/ChatListItem.tsx index 156ed4d31..9e4c96251 100644 --- a/desktop/src/features/chats/ui/ChatListItem.tsx +++ b/desktop/src/features/chats/ui/ChatListItem.tsx @@ -1,9 +1,16 @@ -import { Archive } from "lucide-react"; +import { Archive, Pencil, Pin, PinOff } from "lucide-react"; import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -import { Spinner } from "@/shared/ui/spinner"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/shared/ui/context-menu"; +import { Shimmer } from "@/shared/ui/Shimmer"; export function ChatListHeader() { return ( @@ -17,25 +24,34 @@ export function ChatListHeader() { } export function ChatListItem({ + canRename = true, chat, displayName, getChannelReadAt, isAgentRunning = false, isArchiving = false, + isPinned = false, onArchiveChat, + onRenameChat, onSelectChat, + onTogglePin, selectedChatId, unreadChannelCounts, unreadChannelIds, }: { + /** Renaming writes owner metadata — disabled for shared chats. */ + canRename?: boolean; chat: Channel; /** Preferred label (chat metadata title); falls back to the channel name. */ displayName?: string | null; getChannelReadAt: (channelId: string) => number | null; isAgentRunning?: boolean; isArchiving?: boolean; + isPinned?: boolean; onArchiveChat?: (chatId: string) => void; + onRenameChat?: (chatId: string) => void; onSelectChat: (chatId: string) => void; + onTogglePin?: (chatId: string) => void; selectedChatId: string | null; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; @@ -53,7 +69,7 @@ export function ChatListItem({ const isSelected = selectedChatId === chat.id; - return ( + const row = (
onSelectChat(chat.id)} type="button" > - {name} + + {isAgentRunning ? {name} : name} + + {isPinned ? ( +
); + + if (!onRenameChat && !onTogglePin && !onArchiveChat) { + return row; + } + + return ( + + {row} + + {onRenameChat && canRename ? ( + onRenameChat(chat.id)}> + + Rename chat + + ) : null} + {onTogglePin ? ( + onTogglePin(chat.id)}> + {isPinned ? ( + + ) : ( + + )} + {isPinned ? "Unpin chat" : "Pin chat"} + + ) : null} + {onArchiveChat ? ( + <> + + onArchiveChat(chat.id)} + > + + Archive chat + + + ) : null} + + + ); } diff --git a/desktop/src/features/chats/ui/ChatRenameDialog.tsx b/desktop/src/features/chats/ui/ChatRenameDialog.tsx new file mode 100644 index 000000000..fae16572d --- /dev/null +++ b/desktop/src/features/chats/ui/ChatRenameDialog.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; + +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; + +export function ChatRenameDialog({ + currentTitle, + isSaving = false, + onOpenChange, + onRename, + open, +}: { + currentTitle: string; + isSaving?: boolean; + onOpenChange: (open: boolean) => void; + onRename: (title: string) => void; + open: boolean; +}) { + const [title, setTitle] = React.useState(currentTitle); + + React.useEffect(() => { + if (open) { + setTitle(currentTitle); + } + }, [currentTitle, open]); + + const trimmed = title.trim(); + const canSave = trimmed.length > 0 && !isSaving; + + return ( + + + + Rename chat + + The new name replaces the auto-generated title. + + +
{ + event.preventDefault(); + if (canSave) { + onRename(trimmed); + } + }} + > + setTitle(event.target.value)} + placeholder="Chat name" + value={title} + /> + + + + +
+
+
+ ); +} diff --git a/desktop/src/features/chats/ui/ChatsScreen.tsx b/desktop/src/features/chats/ui/ChatsScreen.tsx index 79986d02e..250f6b65a 100644 --- a/desktop/src/features/chats/ui/ChatsScreen.tsx +++ b/desktop/src/features/chats/ui/ChatsScreen.tsx @@ -5,8 +5,8 @@ import { toast } from "sonner"; import { ChevronDown, ChevronRight, - Folder, MoreVertical, + Notebook, Plus, } from "lucide-react"; @@ -26,6 +26,10 @@ import { useUpdateChatMetadataMutation, } from "@/features/chats/hooks"; import { buildChatProjects } from "@/features/chats/lib/chatProjects"; +import { + toggleStoredChatPin, + useStoredChatPins, +} from "@/features/chats/lib/chatPinStorage"; import { mergeChatProjects, upsertStoredChatProject, @@ -42,6 +46,7 @@ import { ChatListHeader, ChatListItem } from "@/features/chats/ui/ChatListItem"; import { ChatListSectionHeader } from "@/features/chats/ui/ChatListSectionHeader"; import { ChatListSkeleton } from "@/features/chats/ui/ChatListSkeleton"; import { ChatProjectDialog } from "@/features/chats/ui/ChatProjectDialog"; +import { ChatRenameDialog } from "@/features/chats/ui/ChatRenameDialog"; import { QuickStartChat } from "@/features/chats/ui/QuickStartChat"; import { useChannelMessagesQuery, @@ -355,6 +360,56 @@ export function ChatsScreen({ ); const updateMetadataMutation = useUpdateChatMetadataMutation(); + const pinnedChatIds = useStoredChatPins(activeWorkspace?.id); + const handleTogglePin = React.useCallback( + (chatId: string) => { + toggleStoredChatPin(activeWorkspace?.id, chatId); + }, + [activeWorkspace?.id], + ); + const [renamingChatId, setRenamingChatId] = React.useState( + null, + ); + const renamingChat = + renamingChatId !== null + ? (chats.find((chat) => chat.id === renamingChatId) ?? null) + : null; + const renamingMetadata = renamingChatId + ? (metadataByChatId.get(renamingChatId) ?? null) + : null; + const handleRenameChat = React.useCallback( + async (title: string) => { + if (!renamingChat) { + return; + } + const metadata = metadataByChatId.get(renamingChat.id) ?? null; + try { + await updateMetadataMutation.mutateAsync({ + channelId: renamingChat.id, + title, + defaultAgentPubkey: metadata?.defaultAgentPubkey ?? undefined, + templateId: metadata?.templateId ?? undefined, + projectId: metadata?.projectId ?? undefined, + projectName: metadata?.projectName ?? undefined, + projectPath: metadata?.projectPath ?? undefined, + projectTemplateId: metadata?.projectTemplateId ?? undefined, + source: metadata?.sourceChannelId + ? { + channelId: metadata.sourceChannelId, + eventId: metadata.sourceEventId ?? undefined, + threadRootId: metadata.sourceThreadRootId ?? undefined, + } + : undefined, + }); + setRenamingChatId(null); + } catch (error) { + toast.error("Could not rename chat", { + description: error instanceof Error ? error.message : undefined, + }); + } + }, + [metadataByChatId, renamingChat, updateMetadataMutation], + ); const ensuredChatIdsRef = React.useRef(new Set()); React.useEffect(() => { if (!selectedChat || metadataQuery.isLoading) { @@ -515,6 +570,9 @@ export function ChatsScreen({ identityPubkey={identityPubkey} isLoading={chatsQuery.isLoading || metadataListQuery.isLoading} metadataByChatId={metadataByChatId} + onRenameChat={setRenamingChatId} + onTogglePin={handleTogglePin} + pinnedChatIds={pinnedChatIds} onCreateChat={() => void goChats({ projectId: null })} onCreateProjectChat={(projectId) => void goChats({ projectId, replace: true }) @@ -580,6 +638,19 @@ export function ChatsScreen({ /> )} + { + if (!open) { + setRenamingChatId(null); + } + }} + onRename={(title) => void handleRenameChat(title)} + open={renamingChat !== null} + /> ); } @@ -594,8 +665,11 @@ function ChatList({ onArchiveChat, onCreateChat, onCreateProjectChat, + onRenameChat, onSelectChat, + onTogglePin, onUpdateProject, + pinnedChatIds, projects, readStateVersion: _readStateVersion, selectedChatId, @@ -612,7 +686,10 @@ function ChatList({ onArchiveChat: (chatId: string) => void; onCreateChat: () => void; onCreateProjectChat: (projectId: string) => void; + onRenameChat: (chatId: string) => void; onSelectChat: (chatId: string) => void; + onTogglePin: (chatId: string) => void; + pinnedChatIds: ReadonlySet; onUpdateProject: ( project: ReturnType[number], ) => void; @@ -655,8 +732,23 @@ function ChatList({ unprojected.push(chat); } } - return { groups, shared, unprojected }; - }, [chats, identityPubkey, metadataByChatId, projects]); + const pinnedFirst = (list: Channel[]) => + [...list].sort( + (left, right) => + Number(pinnedChatIds.has(right.id)) - + Number(pinnedChatIds.has(left.id)), + ); + return { + groups: new Map( + [...groups.entries()].map(([projectId, group]) => [ + projectId, + pinnedFirst(group), + ]), + ), + shared: pinnedFirst(shared), + unprojected: pinnedFirst(unprojected), + }; + }, [chats, identityPubkey, metadataByChatId, pinnedChatIds, projects]); const toggleProject = React.useCallback((projectId: string) => { setCollapsedProjectIds((current) => { @@ -695,7 +787,7 @@ function ChatList({ return (
- +
} + icon={} label="New project" onSelect={() => { setIsCreateOpen(true); @@ -442,7 +442,7 @@ export function ProjectPicker({ /> } + icon={} label="No project" onSelect={() => { onSelectProject(NO_PROJECT_SELECTION_ID); @@ -479,7 +479,7 @@ function ProjectPickerRow({ onClick={onSelect} type="button" > - + {project.name} {project.path ? ( diff --git a/desktop/tests/e2e/chats-switch-repro.spec.ts b/desktop/tests/e2e/chats-switch-repro.spec.ts index dd285efbc..2ee8ba957 100644 --- a/desktop/tests/e2e/chats-switch-repro.spec.ts +++ b/desktop/tests/e2e/chats-switch-repro.spec.ts @@ -58,4 +58,29 @@ test("switching chats does not stack headers", async ({ page }) => { await expect(page.getByTestId("chat-title")).toContainText( "Second chat about bananas", ); + + // Right-click offers rename/pin/archive; pinning moves the chat to the top + // of its section. + await first.click({ button: "right" }); + await expect( + page.getByRole("menuitem", { name: "Rename chat" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Archive chat" }), + ).toBeVisible(); + await page.getByRole("menuitem", { name: "Pin chat" }).click(); + const chatNames = page + .getByRole("button", { name: /chat about/ }) + .filter({ hasNotText: "Archive" }); + await expect(chatNames.first()).toHaveText(/First chat about apples/); + + // Rename through the context menu updates the sidebar and header. + await first.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Rename chat" }).click(); + const renameInput = page.getByLabel("Chat name"); + await renameInput.fill("Apple planning"); + await page.getByRole("button", { name: "Rename", exact: true }).click(); + await expect( + page.getByRole("button", { exact: true, name: "Apple planning" }), + ).toBeVisible(); });