mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Claude Fable 5
parent
1fe8695c69
commit
7d7babbc2f
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user