Chat sidebar and stream polish

- Sidebar chat titles shimmer while the agent works, replacing the
  spinner (the archive affordance keeps its hover slot).
- Chat projects use the Notebook icon family instead of folders
  (sidebar groups, project picker, new/none rows).
- Right-click on any chat offers Rename (dialog writing owner
  metadata — hidden on shared chats), Pin/Unpin (persisted per
  workspace; pinned chats sort to the top of their section), and
  Archive.
- Solo chats (you plus one agent) hide the agent's avatar and name so
  replies read as a plain stream; identities return automatically the
  moment another agent or person participates.

The switching e2e now covers the context menu, pinning order, and
rename round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
klopez4212
2026-07-07 07:46:12 +01:00
co-authored by Claude Fable 5
parent 1fe8695c69
commit 7d7babbc2f
9 changed files with 452 additions and 58 deletions
@@ -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<string> {
const [pins, setPins] = React.useState<string[]>(() =>
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]);
}
@@ -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<string>;
@@ -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<TranscriptDisplayBlock, { kind: "turn" }>;
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}
/>
))}
</div>
@@ -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<TranscriptItem, { type: "message" }>;
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 (
<Message
className={cn(entrance && "buzz-message-entrance")}
side={isUser ? "right" : "left"}
>
{!isUser ? (
{!isUser && !hideIdentity ? (
<MessageAvatar>
<UserAvatar avatarUrl={avatarUrl} displayName={label} size="sm" />
</MessageAvatar>
) : null}
<MessageContent className={isUser ? "items-end" : "w-full max-w-full"}>
<MessageHeader className={isUser ? "justify-end" : undefined}>
<span className="truncate font-medium">{isUser ? "You" : label}</span>
</MessageHeader>
{!hideIdentity ? (
<MessageHeader className={isUser ? "justify-end" : undefined}>
<span className="truncate font-medium">
{isUser ? "You" : label}
</span>
</MessageHeader>
) : null}
{isUser ? (
<Bubble side="right">
<Markdown
@@ -36,12 +36,19 @@ export function ChatMessageRow({
isAgent,
isOwn,
profiles,
showAgentIdentity = true,
}: {
event: RelayEvent;
isAgent: boolean;
isOwn: boolean;
profiles?: UserProfileLookup;
/**
* Solo chats (one human, one agent) hide the agent's avatar/name so its
* replies read as part of the stream.
*/
showAgentIdentity?: boolean;
}) {
const hideIdentity = isAgent && !showAgentIdentity;
const displayName = profileName(
event.pubkey,
profiles,
@@ -54,7 +61,7 @@ export function ChatMessageRow({
return (
<Message side={isOwn ? "right" : "left"}>
{!isOwn ? (
{!isOwn && !hideIdentity ? (
<MessageAvatar>
<UserAvatar
avatarUrl={profile?.avatarUrl ?? null}
@@ -66,11 +73,13 @@ export function ChatMessageRow({
<MessageContent
className={cn(isOwn && "items-end", isAgent && "w-full max-w-full")}
>
<MessageHeader className={isOwn ? "justify-end" : undefined}>
<span className="truncate font-medium">
{isOwn ? "You" : displayName}
</span>
</MessageHeader>
{!hideIdentity ? (
<MessageHeader className={isOwn ? "justify-end" : undefined}>
<span className="truncate font-medium">
{isOwn ? "You" : displayName}
</span>
</MessageHeader>
) : null}
{isAgent ? (
<Markdown
agentAuthored
@@ -247,6 +247,27 @@ export function ChatDetail({
);
const hasTranscriptActivity = chatActivity.totalBlockCount > 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<string>();
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}
/>
)}
</MessageScrollerItem>
@@ -519,6 +541,7 @@ export function ChatDetail({
blocks={activityBlocks}
identityPubkey={identityPubkey}
activeTurnIds={activeTurnIds}
showAgentIdentity={showAgentIdentity}
profiles={profiles}
/>
</MessageScrollerItem>
@@ -546,6 +569,7 @@ export function ChatDetail({
identityPubkey={identityPubkey}
activeTurnIds={activeTurnIds}
profiles={profiles}
showAgentIdentity={showAgentIdentity}
/>
</MessageScrollerItem>
) : null}
+86 -37
View File
@@ -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<string, number>;
unreadChannelIds: ReadonlySet<string>;
@@ -53,7 +69,7 @@ export function ChatListItem({
const isSelected = selectedChatId === chat.id;
return (
const row = (
<div
className={cn(
"group/chat-row flex h-8 w-full min-w-0 items-center gap-1 rounded-md px-1 text-sm transition-colors",
@@ -67,49 +83,82 @@ export function ChatListItem({
onClick={() => onSelectChat(chat.id)}
type="button"
>
<span className="min-w-0 flex-1 truncate font-medium">{name}</span>
<span className="min-w-0 flex-1 truncate font-medium">
{isAgentRunning ? <Shimmer>{name}</Shimmer> : name}
</span>
{isPinned ? (
<Pin
aria-hidden="true"
className="h-3 w-3 shrink-0 text-muted-foreground/70"
/>
) : null}
{hasUnread ? (
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 text-2xs font-semibold text-primary">
{unreadCount > 0 ? Math.min(unreadCount, 99) : ""}
</span>
) : null}
</button>
{isAgentRunning || onArchiveChat ? (
{onArchiveChat ? (
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center">
{isAgentRunning ? (
<Spinner
aria-label={`Agent is running in ${name}`}
className={cn(
"h-3.5 w-3.5 border-2 transition-opacity",
isSelected
? "text-secondary-foreground/70"
: "text-muted-foreground",
onArchiveChat &&
"group-focus-within/chat-row:opacity-0 group-hover/chat-row:opacity-0",
)}
/>
) : null}
{onArchiveChat ? (
<Button
aria-label={`Archive ${name}`}
className={cn(
"absolute inset-0 h-6 w-6 bg-transparent text-muted-foreground opacity-0 shadow-none transition-[background-color,color,opacity] hover:bg-muted hover:text-foreground focus-visible:bg-muted focus-visible:text-foreground focus-visible:opacity-100 group-focus-within/chat-row:opacity-100 group-hover/chat-row:opacity-100",
isSelected
? "hover:bg-secondary-foreground/10 focus-visible:bg-secondary-foreground/10"
: "hover:bg-muted focus-visible:bg-muted",
)}
disabled={isArchiving}
onClick={() => onArchiveChat(chat.id)}
size="icon-xs"
title="Archive chat"
type="button"
variant="ghost"
>
<Archive className="h-3.5 w-3.5" />
</Button>
) : null}
<Button
aria-label={`Archive ${name}`}
className={cn(
"absolute inset-0 h-6 w-6 bg-transparent text-muted-foreground opacity-0 shadow-none transition-[background-color,color,opacity] hover:bg-muted hover:text-foreground focus-visible:bg-muted focus-visible:text-foreground focus-visible:opacity-100 group-focus-within/chat-row:opacity-100 group-hover/chat-row:opacity-100",
isSelected
? "hover:bg-secondary-foreground/10 focus-visible:bg-secondary-foreground/10"
: "hover:bg-muted focus-visible:bg-muted",
)}
disabled={isArchiving}
onClick={() => onArchiveChat(chat.id)}
size="icon-xs"
title="Archive chat"
type="button"
variant="ghost"
>
<Archive className="h-3.5 w-3.5" />
</Button>
</div>
) : null}
</div>
);
if (!onRenameChat && !onTogglePin && !onArchiveChat) {
return row;
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
<ContextMenuContent className="w-44">
{onRenameChat && canRename ? (
<ContextMenuItem onSelect={() => onRenameChat(chat.id)}>
<Pencil className="h-3.5 w-3.5" />
Rename chat
</ContextMenuItem>
) : null}
{onTogglePin ? (
<ContextMenuItem onSelect={() => onTogglePin(chat.id)}>
{isPinned ? (
<PinOff className="h-3.5 w-3.5" />
) : (
<Pin className="h-3.5 w-3.5" />
)}
{isPinned ? "Unpin chat" : "Pin chat"}
</ContextMenuItem>
) : null}
{onArchiveChat ? (
<>
<ContextMenuSeparator />
<ContextMenuItem
disabled={isArchiving}
onSelect={() => onArchiveChat(chat.id)}
>
<Archive className="h-3.5 w-3.5" />
Archive chat
</ContextMenuItem>
</>
) : null}
</ContextMenuContent>
</ContextMenu>
);
}
@@ -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 (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Rename chat</DialogTitle>
<DialogDescription>
The new name replaces the auto-generated title.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
event.preventDefault();
if (canSave) {
onRename(trimmed);
}
}}
>
<Input
aria-label="Chat name"
autoFocus
onChange={(event) => setTitle(event.target.value)}
placeholder="Chat name"
value={title}
/>
<DialogFooter className="mt-4">
<Button
onClick={() => onOpenChange(false)}
type="button"
variant="ghost"
>
Cancel
</Button>
<Button disabled={!canSave} type="submit">
Rename
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+106 -4
View File
@@ -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<string | null>(
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<string>());
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({
/>
)}
</main>
<ChatRenameDialog
currentTitle={
renamingMetadata?.title?.trim() || renamingChat?.name || ""
}
isSaving={updateMetadataMutation.isPending}
onOpenChange={(open) => {
if (!open) {
setRenamingChatId(null);
}
}}
onRename={(title) => void handleRenameChat(title)}
open={renamingChat !== null}
/>
</div>
);
}
@@ -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<string>;
onUpdateProject: (
project: ReturnType<typeof buildChatProjects>[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 (
<div key={project.id} className="mb-1">
<div className="group/project flex h-8 w-full min-w-0 items-center gap-1.5 rounded-md px-2 text-xs font-semibold text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground">
<Folder className="h-3.5 w-3.5 shrink-0" />
<Notebook className="h-3.5 w-3.5 shrink-0" />
<button
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
onClick={() => toggleProject(project.id)}
@@ -749,9 +841,12 @@ function ChatList({
getChannelReadAt={getChannelReadAt}
isAgentRunning={activeChatIds.has(chat.id)}
isArchiving={archivingChatId === chat.id}
isPinned={pinnedChatIds.has(chat.id)}
key={chat.id}
onArchiveChat={onArchiveChat}
onRenameChat={onRenameChat}
onSelectChat={onSelectChat}
onTogglePin={onTogglePin}
selectedChatId={selectedChatId}
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
@@ -782,9 +877,12 @@ function ChatList({
getChannelReadAt={getChannelReadAt}
isAgentRunning={activeChatIds.has(chat.id)}
isArchiving={archivingChatId === chat.id}
isPinned={pinnedChatIds.has(chat.id)}
key={chat.id}
onArchiveChat={onArchiveChat}
onRenameChat={onRenameChat}
onSelectChat={onSelectChat}
onTogglePin={onTogglePin}
selectedChatId={selectedChatId}
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
@@ -801,14 +899,18 @@ function ChatList({
<ChatListSectionHeader label="Shared" />
{chatsByProject.shared.map((chat) => (
<ChatListItem
canRename={false}
chat={chat}
displayName={metadataByChatId.get(chat.id)?.title}
getChannelReadAt={getChannelReadAt}
isAgentRunning={activeChatIds.has(chat.id)}
isArchiving={archivingChatId === chat.id}
isPinned={pinnedChatIds.has(chat.id)}
key={chat.id}
onArchiveChat={onArchiveChat}
onRenameChat={onRenameChat}
onSelectChat={onSelectChat}
onTogglePin={onTogglePin}
selectedChatId={selectedChatId}
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
@@ -3,9 +3,9 @@ import { useQueryClient } from "@tanstack/react-query";
import {
Check,
ChevronDown,
Folder,
FolderPlus,
FolderX,
Notebook,
NotebookPen,
NotepadTextDashed,
Search,
} from "lucide-react";
import { toast } from "sonner";
@@ -393,7 +393,7 @@ export function ProjectPicker({
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<SetupPill className="max-w-64" testId="chat-project-picker">
<Folder className="h-4 w-4 shrink-0" />
<Notebook className="h-4 w-4 shrink-0" />
<span className="truncate">
{selectedProject?.name ||
(isNoProjectSelected ? "No project" : "Project")}
@@ -433,7 +433,7 @@ export function ProjectPicker({
</div>
<div className="my-2 border-t border-border/60" />
<ProjectModeRow
icon={<FolderPlus className="h-4 w-4" />}
icon={<NotebookPen className="h-4 w-4" />}
label="New project"
onSelect={() => {
setIsCreateOpen(true);
@@ -442,7 +442,7 @@ export function ProjectPicker({
/>
<ProjectModeRow
checked={isNoProjectSelected}
icon={<FolderX className="h-4 w-4" />}
icon={<NotepadTextDashed className="h-4 w-4" />}
label="No project"
onSelect={() => {
onSelectProject(NO_PROJECT_SELECTION_ID);
@@ -479,7 +479,7 @@ function ProjectPickerRow({
onClick={onSelect}
type="button"
>
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
<Notebook className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{project.name}</span>
{project.path ? (
@@ -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();
});