evOo-local/apps/web/src/pages/shell/roster-rows.tsx
Claude 3c678519a6
refactor(web): extract ShellPage sidebar JSX into components
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
2026-10-04 10:08:43 +00:00

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>
);
}