ShellPage's JSX carried the whole sidebar inline, including two near-copies of the group header and of the main header's sidebar toggles, which made the render hard to follow and easy to change in one place only. Move-only: the sidebar header, group header (one component for the conversation and project variants), the team and project roster rows, the archived section, the user menu and the sidebar toggle buttons now live in pages/shell. DOM, classNames, test ids, aria attributes, handlers and messages are unchanged; the catalogs only update their file references, and the desktop source checks for the header toggles now read the file they moved to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
251 lines
8.3 KiB
TypeScript
251 lines
8.3 KiB
TypeScript
import { Trans, useLingui } from "@lingui/react/macro";
|
|
import type { SpaceBot, SpaceGroup } from "@rakazo/contracts";
|
|
import { BotAvatar, GroupAvatar } from "@rakazo/ui-web";
|
|
import type { RefObject } from "react";
|
|
import {
|
|
needsYouChipLabel,
|
|
rosterActivitySubtitle,
|
|
rosterStatusDot,
|
|
} from "../../lib/agent-activity";
|
|
import { localizedPreview } from "../../lib/onboarding-copy";
|
|
import type { ContextMenuPosition } from "../BotContextMenu";
|
|
import { cleanRosterPreview, formatRosterTime } from "./roster-format";
|
|
import {
|
|
rosterNameClass,
|
|
rosterPreviewClass,
|
|
rosterRowClass,
|
|
rosterTimeClass,
|
|
rosterUnreadDotClass,
|
|
} from "./roster-row-style";
|
|
|
|
export type SidebarChatItem = { kind: "bot"; chat: SpaceBot } | { kind: "group"; chat: SpaceGroup };
|
|
|
|
export type BotMenuRequest = {
|
|
kind: "bot" | "group";
|
|
id: string;
|
|
position: ContextMenuPosition;
|
|
};
|
|
|
|
/** A bot in the conversations section: reorderable by drag or Alt+Arrow within its group. */
|
|
export function TeamRosterRow({
|
|
item,
|
|
groupBotIds,
|
|
draggedBotId,
|
|
onDraggedBotIdChange,
|
|
onReorder,
|
|
selected,
|
|
onOpen,
|
|
activeSpaceId,
|
|
menuAnchor,
|
|
onMenu,
|
|
}: {
|
|
item: Extract<SidebarChatItem, { kind: "bot" }>;
|
|
groupBotIds: string[];
|
|
draggedBotId: string | null;
|
|
onDraggedBotIdChange: (botId: string | null) => void;
|
|
onReorder: (sourceId: string, targetId: string, groupBotIds: string[]) => void;
|
|
selected: boolean;
|
|
onOpen: () => void;
|
|
activeSpaceId: string | undefined;
|
|
menuAnchor: RefObject<HTMLElement | null>;
|
|
onMenu: (menu: BotMenuRequest) => void;
|
|
}) {
|
|
const { t } = useLingui();
|
|
return (
|
|
<button
|
|
type="button"
|
|
draggable={item.kind === "bot"}
|
|
data-roster-bot-id={item.kind === "bot" ? item.chat.id : undefined}
|
|
aria-keyshortcuts={item.kind === "bot" ? "Alt+ArrowUp Alt+ArrowDown" : undefined}
|
|
onDragStart={(event) => {
|
|
if (item.kind !== "bot") return;
|
|
onDraggedBotIdChange(item.chat.id);
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData("text/plain", item.chat.id);
|
|
}}
|
|
onDragOver={(event) => {
|
|
if (item.kind === "bot" && draggedBotId && groupBotIds.includes(draggedBotId)) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
}
|
|
}}
|
|
onDrop={(event) => {
|
|
if (item.kind !== "bot" || !draggedBotId) return;
|
|
event.preventDefault();
|
|
onReorder(draggedBotId, item.chat.id, groupBotIds);
|
|
onDraggedBotIdChange(null);
|
|
}}
|
|
onDragEnd={() => onDraggedBotIdChange(null)}
|
|
onKeyDown={(event) => {
|
|
if (
|
|
item.kind !== "bot" ||
|
|
!event.altKey ||
|
|
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
|
|
)
|
|
return;
|
|
const index = groupBotIds.indexOf(item.chat.id);
|
|
const target = groupBotIds[index + (event.key === "ArrowUp" ? -1 : 1)];
|
|
if (!target) return;
|
|
event.preventDefault();
|
|
onReorder(item.chat.id, target, groupBotIds);
|
|
}}
|
|
aria-label={
|
|
item.kind === "bot"
|
|
? t`${item.chat.name}, 1:1 conversation`
|
|
: t`${item.chat.name}, group conversation`
|
|
}
|
|
onClick={onOpen}
|
|
onContextMenu={(event) => {
|
|
if (item.chat.spaceId !== activeSpaceId) return;
|
|
event.preventDefault();
|
|
menuAnchor.current = event.currentTarget;
|
|
onMenu({
|
|
kind: item.kind,
|
|
id: item.chat.id,
|
|
position: { x: event.clientX, y: event.clientY },
|
|
});
|
|
}}
|
|
className={`${rosterRowClass(selected)} cursor-grab active:cursor-grabbing`}
|
|
style={{
|
|
opacity: draggedBotId === item.chat.id ? 0.55 : 1,
|
|
}}
|
|
>
|
|
<div className="relative shrink-0">
|
|
<BotAvatar
|
|
color={item.chat.color}
|
|
identity={item.chat.id}
|
|
size={40}
|
|
status={item.chat.status}
|
|
/>
|
|
<span
|
|
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-sidebar ${
|
|
rosterStatusDot(item.chat.status) === "idle" ? "bg-success" : "bg-warning animate-pulse"
|
|
}`}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-between gap-1.5">
|
|
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
|
<span
|
|
dir="auto"
|
|
data-roster-bot-name=""
|
|
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${rosterNameClass(
|
|
item.chat.unread,
|
|
)}`}
|
|
>
|
|
{item.chat.name}
|
|
</span>
|
|
{needsYouChipLabel(item.chat.status) ? (
|
|
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-foreground dark:text-warning">
|
|
{needsYouChipLabel(item.chat.status)}
|
|
</span>
|
|
) : item.chat.title ? (
|
|
<span className="min-w-0 truncate rounded-full border border-border bg-muted px-2 py-0.5 text-ui-label font-medium text-muted-foreground">
|
|
{item.chat.title}
|
|
</span>
|
|
) : null}
|
|
{item.chat.unread ? (
|
|
<span className="sr-only">
|
|
<Trans> (unread)</Trans>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
<span className={rosterTimeClass}>{formatRosterTime(item.chat.updatedAt)}</span>
|
|
{item.chat.unread ? <span aria-hidden="true" className={rosterUnreadDotClass} /> : null}
|
|
</div>
|
|
</div>
|
|
<div
|
|
dir="auto"
|
|
className={`mt-0.5 truncate text-ui-body leading-snug ${rosterPreviewClass(
|
|
item.chat.unread,
|
|
)}`}
|
|
>
|
|
{rosterActivitySubtitle(item.chat.status, cleanRosterPreview(item.chat.preview))}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** A conversation in the projects section; a project's main conversation has no chat actions. */
|
|
export function ProjectRosterRow({
|
|
item,
|
|
selected,
|
|
onOpen,
|
|
activeSpaceId,
|
|
menuAnchor,
|
|
onMenu,
|
|
}: {
|
|
item: SidebarChatItem;
|
|
selected: boolean;
|
|
onOpen: () => void;
|
|
activeSpaceId: string | undefined;
|
|
menuAnchor: RefObject<HTMLElement | null>;
|
|
onMenu: (menu: BotMenuRequest) => void;
|
|
}) {
|
|
const { t } = useLingui();
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={
|
|
item.kind === "bot"
|
|
? t`${item.chat.name}, 1:1 conversation`
|
|
: item.chat.kind === "main"
|
|
? t`${item.chat.name}, main conversation`
|
|
: t`${item.chat.name}, group conversation`
|
|
}
|
|
onClick={onOpen}
|
|
onContextMenu={(event) => {
|
|
// A project's main conversation has no chat actions.
|
|
if (item.chat.spaceId !== activeSpaceId || item.chat.kind === "main") return;
|
|
event.preventDefault();
|
|
menuAnchor.current = event.currentTarget;
|
|
onMenu({
|
|
kind: item.kind,
|
|
id: item.chat.id,
|
|
position: { x: event.clientX, y: event.clientY },
|
|
});
|
|
}}
|
|
className={rosterRowClass(selected)}
|
|
>
|
|
{item.kind === "bot" ? (
|
|
<BotAvatar
|
|
color={item.chat.color}
|
|
identity={item.chat.id}
|
|
size={40}
|
|
status={item.chat.status}
|
|
/>
|
|
) : (
|
|
<GroupAvatar members={(item.chat as any).members} size={40} />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-between gap-1.5">
|
|
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
|
<span
|
|
dir="auto"
|
|
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${rosterNameClass(
|
|
item.chat.unread,
|
|
)}`}
|
|
>
|
|
{item.chat.name}
|
|
</span>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
<span className={rosterTimeClass}>{formatRosterTime(item.chat.updatedAt)}</span>
|
|
{item.chat.unread ? <span aria-hidden="true" className={rosterUnreadDotClass} /> : null}
|
|
</div>
|
|
</div>
|
|
<div
|
|
dir="auto"
|
|
className={`mt-0.5 truncate text-ui-body leading-snug ${rosterPreviewClass(
|
|
item.chat.unread,
|
|
)}`}
|
|
>
|
|
{cleanRosterPreview(localizedPreview(item.chat))}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|