Cap rail bot and workspace lists at five with see-all summary

This commit is contained in:
2026-10-19 20:11:04 +01:00
parent b0c9c84b2b
commit b79cebf397
8 changed files with 146 additions and 21 deletions
@@ -5,6 +5,7 @@ import { twMerge } from "tailwind-merge";
import profileImageUrls from "@/src/utils/profile-image-urls";
const WorkspaceAvatarSizes = {
xs: { box: "size-[18px] rounded-[4px]", icon: 12 },
sm: { box: "size-8 rounded-default-sm", icon: 16 },
lg: { box: "size-14 rounded-default", icon: 24 },
} as const;
+8 -3
View File
@@ -252,11 +252,16 @@ export const AppData = {
* Worker bots the rail lists, most recently active first, before
* "See all bots".
*/
RailRecentBots: 8,
RailRecentBots: 5,
/**
* Stones shown beside "See all bots".
* Workspaces the rail lists, most recently active first, before
* "See all workspaces".
*/
RailSeeAllStones: 4,
RailRecentWorkspaces: 5,
/**
* Stones shown beside "See all bots" and "See all workspaces".
*/
RailSeeAllStones: 3,
/**
* Recent sessions the rail lists under the open bot, before "All
* sessions".
@@ -0,0 +1,51 @@
import { useState } from "react";
import Input from "@moduletrace/twui/form/Input";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import type { WorkspaceWithActivity } from "@/src/types";
import AddModalShell from "./add-modal-shell";
import AllWorkspacesRow from "./all-workspaces-row";
type Props = {
open: boolean;
setOpen: (open: boolean) => void;
workspaces: WorkspaceWithActivity[];
};
/**
* Every workspace, latest activity first, with a search by name or
* description. A workspace opens its page.
*/
export default function AllWorkspacesModal({ open, setOpen, workspaces }: Props) {
const [query, setQuery] = useState("");
const search = query.trim().toLowerCase();
const found = search
? workspaces.filter((workspace) =>
`${workspace.name} ${workspace.description || ""}`.toLowerCase().includes(search),
)
: workspaces;
return (
<AddModalShell open={open} setOpen={setOpen} title="Workspaces" description={`${workspaces.length} in all.`}>
<Input
id="all-workspaces-search"
title="Search workspaces"
placeholder="Search by name or description"
autoComplete="off"
defaultValue={query}
onChange={(e) => setQuery(e.target.value)}
wrapperWrapperProps={{ className: "mt-4" }}
/>
<Stack className="w-full mt-2 max-h-[60vh] overflow-y-auto gap-0! items-stretch">
{found.map((workspace) => (
<AllWorkspacesRow key={workspace.id} workspace={workspace} />
))}
{found.length ? null : (
<Span className="px-3 py-6 text-[13px] leading-[18px] text-ink-secondary">
No workspace matches "{query.trim()}".
</Span>
)}
</Stack>
</AddModalShell>
);
}
@@ -0,0 +1,37 @@
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import type { WorkspaceWithActivity } from "@/src/types";
import relativeTime from "@/src/utils/relative-time";
type Props = {
workspace: WorkspaceWithActivity;
};
/**
* One workspace in the all-workspaces modal: tile, name, description, last
* activity.
*/
export default function AllWorkspacesRow({ workspace }: Props) {
return (
<Link
href={`/admin/workspaces/${workspace.id}`}
title={workspace.name}
className="flex h-14 items-center gap-3 rounded-default px-3 border-0 leading-normal hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<WorkspaceAvatar image_media_id={workspace.image_media_id} />
<Stack className="grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">{workspace.name}</Span>
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
{workspace.unread ? "New messages" : workspace.description || ""}
</Span>
</Stack>
{workspace.last_activity_at ? (
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">
{relativeTime({ at: Number(workspace.last_activity_at) })}
</Span>
) : null}
</Link>
);
}
@@ -7,6 +7,7 @@ import AllBotsModal from "./all-bots-modal";
import RailBotEntry from "./rail-bot-entry";
import RailSectionLabel from "./rail-section-label";
import RailSeeAllRow from "./rail-see-all-row";
import RailSeeAllStone from "./rail-see-all-stone";
type Props = {
title: string;
@@ -55,7 +56,9 @@ export default function RailBotsSection({ title, workers, selected_id, workspace
)}
{hidden.length ? (
<RailSeeAllRow
hidden={hidden}
stones={hidden.slice(0, AppData.RailSeeAllStones).map((bot) => (
<RailSeeAllStone key={bot.id} bot={bot} />
))}
label={`See all ${workers.length} bots`}
onOpen={() => setOpen(true)}
/>
@@ -1,26 +1,24 @@
import type { ReactNode } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { Ellipsis } from "lucide-react";
import { AppData } from "@/src/data/app-data";
import type { BotWithLastMessage } from "@/src/types";
import RailSeeAllStone from "./rail-see-all-stone";
type Props = {
/**
* The bots the rail doesn't list.
* A few of the records the rail doesn't list, e.g. their stones.
*/
hidden: BotWithLastMessage[];
stones?: ReactNode;
label: string;
onOpen: () => void;
};
/**
* Under the listed bots: a few stones of the ones that aren't listed and
* "See all bots", which opens them all. An ellipsis when the rail
* Under the listed records: a few stones of the ones that aren't listed and
* "See all", which opens every one in a modal. An ellipsis when the rail
* collapses.
*/
export default function RailSeeAllRow({ hidden, label, onOpen }: Props) {
export default function RailSeeAllRow({ stones, label, onOpen }: Props) {
return (
<Button
title={label}
@@ -31,12 +29,10 @@ export default function RailSeeAllRow({ hidden, label, onOpen }: Props) {
className="flex shrink-0 h-10 w-full items-center justify-center @rail:justify-start gap-3! rounded-default px-1! @rail:px-3! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<Ellipsis size={18} aria-hidden className="@rail:hidden" />
<Row className="hidden @rail:flex shrink-0 gap-1! flex-nowrap items-center">
{hidden.slice(0, AppData.RailSeeAllStones).map((bot) => (
<RailSeeAllStone key={bot.id} bot={bot} />
))}
</Row>
<Span className="hidden @rail:inline text-[13px] font-bold leading-4">{label}</Span>
{stones ? (
<Row className="hidden @rail:flex shrink-0 gap-1! flex-nowrap items-center">{stones}</Row>
) : null}
<Span className="hidden @rail:inline text-[13px] font-bold leading-4 truncate">{label}</Span>
</Button>
);
}
@@ -0,0 +1,13 @@
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import type { WorkspaceWithActivity } from "@/src/types";
type Props = {
workspace: WorkspaceWithActivity;
};
/**
* One small workspace tile beside "See all workspaces".
*/
export default function RailSeeAllWorkspace({ workspace }: Props) {
return <WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" />;
}
@@ -1,9 +1,13 @@
import { useContext } from "react";
import { useContext, useState } from "react";
import Span from "@moduletrace/twui/layout/Span";
import { AppData } from "@/src/data/app-data";
import type { WorkspaceWithActivity } from "@/src/types";
import { AddFlowContext } from "../(data)/add-flow-context";
import AllWorkspacesModal from "./all-workspaces-modal";
import RailSectionAddButton from "./rail-section-add-button";
import RailSectionLabel from "./rail-section-label";
import RailSeeAllRow from "./rail-see-all-row";
import RailSeeAllWorkspace from "./rail-see-all-workspace";
import WorkspaceRow from "./workspace-row";
type Props = {
@@ -12,10 +16,15 @@ type Props = {
};
/**
* The user's workspaces, with a "+" to create one.
* The user's workspaces, with a "+" to create one: the latest
* `AppData.RailRecentWorkspaces`, then "See all workspaces" for the rest,
* which lists them all in a modal.
*/
export default function RailWorkspacesSection({ workspaces, selected_id }: Props) {
const { openForm } = useContext(AddFlowContext);
const [open, setOpen] = useState(false);
const shown = workspaces.slice(0, AppData.RailRecentWorkspaces);
const hidden = workspaces.slice(AppData.RailRecentWorkspaces);
return (
<>
@@ -28,7 +37,7 @@ export default function RailWorkspacesSection({ workspaces, selected_id }: Props
/>
}
/>
{workspaces.map((workspace) => (
{shown.map((workspace) => (
<WorkspaceRow key={workspace.id} workspace={workspace} selected={workspace.id == selected_id} />
))}
{workspaces.length ? null : (
@@ -36,6 +45,16 @@ export default function RailWorkspacesSection({ workspaces, selected_id }: Props
No workspaces yet. A workspace keeps a project's context, bots and computers together.
</Span>
)}
{hidden.length ? (
<RailSeeAllRow
stones={hidden.slice(0, AppData.RailSeeAllStones).map((workspace) => (
<RailSeeAllWorkspace key={workspace.id} workspace={workspace} />
))}
label={`See all ${workspaces.length} workspaces`}
onOpen={() => setOpen(true)}
/>
) : null}
<AllWorkspacesModal open={open} setOpen={setOpen} workspaces={workspaces} />
</>
);
}