mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat(site): design improvements for projects page (#30740)
<img width="1211" height="799" alt="Screenshot 2026-10-14 at 15 29 37" src="https://github.com/user-attachments/assets/3a62b3e7-1e97-4d76-b643-9a6da9ae690d" /> 1. Improves spacing and positioning of elements 2. Move meta data to details section
This commit is contained in:
@@ -425,6 +425,7 @@ const AgentCreatePageContent: React.FC<AgentCreatePageContentProps> = ({
|
||||
return (
|
||||
<>
|
||||
<AgentPageHeader
|
||||
floating={project !== undefined}
|
||||
chimeEnabled={chimeEnabled}
|
||||
onToggleChime={handleChimeToggle}
|
||||
webPush={webPush}
|
||||
|
||||
@@ -535,7 +535,7 @@ const AgentsPageLayout: React.FC = () => {
|
||||
<div
|
||||
data-testid="agents-main-panel"
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 flex-1 flex-col bg-surface-primary",
|
||||
"min-h-0 min-w-0 flex-1 flex-col bg-surface-primary sm:relative",
|
||||
isSettingsIndex ? "hidden sm:flex" : "flex",
|
||||
!agentId &&
|
||||
!isSettingsDetail &&
|
||||
|
||||
@@ -1334,8 +1334,10 @@ export const AgentChatInput: React.FC<AgentChatInputProps> = ({
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full pb-0 sm:pb-4",
|
||||
fillWidth ? "max-w-full" : chatWidthClass(chatFullWidth),
|
||||
"mx-auto w-full",
|
||||
fillWidth
|
||||
? "max-w-full"
|
||||
: [chatWidthClass(chatFullWidth), "pb-0 sm:pb-4"],
|
||||
isEditingHistoryMessage && "pt-1",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from "cn";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BellIcon,
|
||||
@@ -36,6 +37,8 @@ type AgentPageHeaderProps = {
|
||||
/** When set, shows a back link on mobile instead of the logo
|
||||
* and hides the mobile actions menu. */
|
||||
mobileBack?: { to: To; label: string };
|
||||
/** On desktop, overlays the page instead of taking its own row. */
|
||||
floating?: boolean;
|
||||
chimeEnabled?: boolean;
|
||||
onToggleChime?: () => void;
|
||||
webPush?: ReturnType<typeof useWebpushNotifications>;
|
||||
@@ -45,6 +48,7 @@ type AgentPageHeaderProps = {
|
||||
export const AgentPageHeader: React.FC<AgentPageHeaderProps> = ({
|
||||
children,
|
||||
mobileBack,
|
||||
floating = false,
|
||||
chimeEnabled: controlledChimeEnabled,
|
||||
onToggleChime,
|
||||
webPush: controlledWebPush,
|
||||
@@ -112,7 +116,14 @@ export const AgentPageHeader: React.FC<AgentPageHeaderProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="order-first flex shrink-0 items-center gap-2 pl-4 pr-2 pt-3 pb-0.5 sm:order-0 sm:px-4 sm:py-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
"order-first flex shrink-0 items-center gap-2 pl-4 pr-2 pt-3 pb-0.5 sm:order-0 sm:px-4 sm:py-0.5",
|
||||
// The row spans the page width, so only its buttons take clicks.
|
||||
floating &&
|
||||
"sm:pointer-events-none sm:absolute sm:inset-x-0 sm:top-0 sm:z-10",
|
||||
)}
|
||||
>
|
||||
{mobileBack ? (
|
||||
<Button
|
||||
asChild
|
||||
@@ -138,14 +149,16 @@ export const AgentPageHeader: React.FC<AgentPageHeaderProps> = ({
|
||||
size="icon"
|
||||
onClick={onExpandSidebar}
|
||||
aria-label="Expand sidebar"
|
||||
className="hidden size-7 min-w-0 shrink-0 sm:inline-flex"
|
||||
className="pointer-events-auto hidden size-7 min-w-0 shrink-0 sm:inline-flex"
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
</Button>
|
||||
)}
|
||||
<div className="min-w-0 flex-1" />
|
||||
{children && isDesktop && (
|
||||
<div className="hidden items-center gap-2 sm:flex">{children}</div>
|
||||
<div className="pointer-events-auto hidden items-center gap-2 sm:flex">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{/* Mobile: meatball menu with all actions */}
|
||||
{!mobileBack && !isDesktop && (
|
||||
|
||||
@@ -66,7 +66,9 @@ export const ProjectChatsListView: React.FC<ProjectChatsListViewProps> = ({
|
||||
)
|
||||
) : chats.length === 0 ? (
|
||||
<>
|
||||
<div className="rounded-lg border border-dashed border-border px-4 py-8 text-center text-sm text-content-secondary">
|
||||
{/* Fills the grid row so the box matches the details panel
|
||||
beside it. */}
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-border px-4 py-8 text-center text-sm text-content-secondary">
|
||||
No chats yet. Every chat will leverage the same settings and
|
||||
history.
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
ChatProject,
|
||||
ChatProjectInstructions,
|
||||
} from "#/api/typesGenerated";
|
||||
import { TooltipProvider } from "#/components/Tooltip/Tooltip";
|
||||
import { ThemeOverride } from "#/contexts/ThemeProvider";
|
||||
import {
|
||||
MockChatProject,
|
||||
@@ -23,6 +24,23 @@ import { createTestQueryClient } from "#/testHelpers/renderHelpers";
|
||||
import themes, { DEFAULT_THEME } from "#/theme";
|
||||
import { ProjectDetailsPanel } from "./ProjectDetailsPanel";
|
||||
|
||||
vi.mock("#/hooks/useAuthenticated", async () => {
|
||||
const { MockUserOwner } = await import("#/testHelpers/entities");
|
||||
return {
|
||||
useAuthenticated: () => ({ user: MockUserOwner, permissions: {} }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("#/modules/dashboard/useDashboard", async () => {
|
||||
const { MockDefaultOrganization } = await import("#/testHelpers/entities");
|
||||
return {
|
||||
useDashboard: () => ({
|
||||
organizations: [MockDefaultOrganization],
|
||||
showOrganizations: true,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mockInstructions = (instructions: ChatProjectInstructions) =>
|
||||
vi
|
||||
.spyOn(API.experimental, "getChatProjectInstructions")
|
||||
@@ -57,16 +75,18 @@ const confirmDelete = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderPanel = (project: ChatProject = MockChatProject) => {
|
||||
const renderPanel = () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const panel = (project: ChatProject) => (
|
||||
<ThemeOverride theme={themes[DEFAULT_THEME]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProjectDetailsPanel project={project} />
|
||||
<TooltipProvider>
|
||||
<ProjectDetailsPanel project={project} />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeOverride>
|
||||
);
|
||||
const { rerender } = render(panel(project));
|
||||
const { rerender } = render(panel(MockChatProject));
|
||||
return {
|
||||
user: userEvent.setup(),
|
||||
queryClient,
|
||||
@@ -91,7 +111,9 @@ describe("ProjectDetailsPanel", () => {
|
||||
const getInstructions = mockInstructions(MockUnsetChatProjectInstructions);
|
||||
const { user, queryClient } = renderPanel();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Create" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Create instructions" }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Instructions" }),
|
||||
text,
|
||||
@@ -210,7 +232,9 @@ describe("ProjectDetailsPanel", () => {
|
||||
mockInstructions(MockUnsetChatProjectInstructions);
|
||||
const { user } = renderPanel();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Create" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Create instructions" }),
|
||||
);
|
||||
const textbox = screen.getByRole("textbox", { name: "Instructions" });
|
||||
await user.type(textbox, "Too long");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
@@ -265,7 +289,9 @@ describe("ProjectDetailsPanel", () => {
|
||||
mockInstructions(MockUnsetChatProjectInstructions);
|
||||
const { user } = renderPanel();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Create" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Create instructions" }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Instructions" }),
|
||||
"Be brief.",
|
||||
@@ -291,7 +317,9 @@ describe("ProjectDetailsPanel", () => {
|
||||
mockInstructions(MockUnsetChatProjectInstructions);
|
||||
const { user } = renderPanel();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Create" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Create instructions" }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Instructions" }),
|
||||
"Be brief.",
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
deleteChatProjectInstructions,
|
||||
updateChatProjectInstructions,
|
||||
} from "#/api/queries/chatProjects";
|
||||
import { user } from "#/api/queries/users";
|
||||
import type { ChatProject } from "#/api/typesGenerated";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { ProjectDetailsPanelView } from "./ProjectDetailsPanelView";
|
||||
import { ProjectInstructionsDialog } from "./ProjectInstructionsDialog";
|
||||
|
||||
@@ -27,6 +30,25 @@ const ProjectDetailsPanelContent: React.FC<ProjectDetailsPanelProps> = ({
|
||||
project,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { user: me } = useAuthenticated();
|
||||
const { organizations, showOrganizations } = useDashboard();
|
||||
const isOwner = project.owner_id === me.id;
|
||||
const ownerQuery = useQuery({ ...user(project.owner_id), enabled: !isOwner });
|
||||
const ownerLabel = isOwner
|
||||
? "You"
|
||||
: ownerQuery.data
|
||||
? ownerQuery.data.name || ownerQuery.data.username
|
||||
: ownerQuery.isError
|
||||
? "Unknown"
|
||||
: undefined;
|
||||
const organization = organizations.find(
|
||||
(org) => org.id === project.organization_id,
|
||||
);
|
||||
const organizationLabel = showOrganizations
|
||||
? organization
|
||||
? organization.display_name || organization.name
|
||||
: "Unknown"
|
||||
: undefined;
|
||||
const instructionsQuery = useQuery(chatProjectInstructions(project));
|
||||
const updateMutation = useMutation(
|
||||
updateChatProjectInstructions(queryClient, project),
|
||||
@@ -64,6 +86,9 @@ const ProjectDetailsPanelContent: React.FC<ProjectDetailsPanelProps> = ({
|
||||
return (
|
||||
<>
|
||||
<ProjectDetailsPanelView
|
||||
ownerLabel={ownerLabel}
|
||||
createdAt={project.created_at}
|
||||
organizationLabel={organizationLabel}
|
||||
instructions={instructionsQuery.data}
|
||||
error={instructionsQuery.error}
|
||||
onRetry={() => void instructionsQuery.refetch()}
|
||||
|
||||
+55
-2
@@ -1,10 +1,12 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { fn, within } from "storybook/test";
|
||||
import {
|
||||
MockChatProject,
|
||||
MockChatProjectInstructions,
|
||||
MockUnsetChatProjectInstructions,
|
||||
mockApiError,
|
||||
} from "#/testHelpers/entities";
|
||||
import { DATE_FORMAT, formatDateTime } from "#/utils/time";
|
||||
import { ProjectDetailsPanelView } from "./ProjectDetailsPanelView";
|
||||
|
||||
const meta: Meta<typeof ProjectDetailsPanelView> = {
|
||||
@@ -12,12 +14,14 @@ const meta: Meta<typeof ProjectDetailsPanelView> = {
|
||||
component: ProjectDetailsPanelView,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-[30rem]">
|
||||
<div className="max-w-[310px]">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
ownerLabel: "You",
|
||||
createdAt: MockChatProject.created_at,
|
||||
instructions: MockChatProjectInstructions,
|
||||
error: undefined,
|
||||
onRetry: fn(),
|
||||
@@ -28,6 +32,46 @@ const meta: Meta<typeof ProjectDetailsPanelView> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ProjectDetailsPanelView>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithOrganization: Story = {
|
||||
args: { organizationLabel: "Coder" },
|
||||
};
|
||||
|
||||
export const OwnerLoading: Story = {
|
||||
args: { ownerLabel: undefined },
|
||||
};
|
||||
|
||||
export const OtherOwner: Story = {
|
||||
args: { ownerLabel: "Jane Doe" },
|
||||
};
|
||||
|
||||
export const OwnerUnknown: Story = {
|
||||
args: { ownerLabel: "Unknown" },
|
||||
};
|
||||
|
||||
/** Long owner and organization names wrap so they stay readable. */
|
||||
export const LongNames: Story = {
|
||||
args: {
|
||||
ownerLabel: "bartholomewmontgomeryfitzgerald1",
|
||||
organizationLabel: "Platform-Infrastructure-Engineering-Europe",
|
||||
},
|
||||
};
|
||||
|
||||
/** Keyboard focus on the creation date shows the full timestamp. */
|
||||
export const CreatedDateFocused: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
within(canvasElement)
|
||||
.getByRole("button", {
|
||||
name: formatDateTime(
|
||||
MockChatProject.created_at,
|
||||
DATE_FORMAT.MEDIUM_DATE,
|
||||
),
|
||||
})
|
||||
.focus();
|
||||
},
|
||||
};
|
||||
|
||||
export const Unset: Story = {
|
||||
args: { instructions: MockUnsetChatProjectInstructions },
|
||||
};
|
||||
@@ -58,3 +102,12 @@ export const LoadError: Story = {
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** A failed refetch keeps the cached instructions and shows the error. */
|
||||
export const RefetchError: Story = {
|
||||
args: {
|
||||
error: mockApiError({
|
||||
message: "Failed to get chat project instructions.",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,8 +4,19 @@ import type { ChatProjectInstructions } from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Skeleton } from "#/components/Skeleton/Skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { DATE_FORMAT, formatDateTime } from "#/utils/time";
|
||||
|
||||
type ProjectDetailsPanelViewProps = {
|
||||
/** The owner's display label. `undefined` while it loads. */
|
||||
readonly ownerLabel: string | undefined;
|
||||
readonly createdAt: string;
|
||||
/** Omit to hide the organization row. */
|
||||
readonly organizationLabel?: string;
|
||||
/** `undefined` until the instructions load. */
|
||||
readonly instructions: ChatProjectInstructions | undefined;
|
||||
readonly error: unknown;
|
||||
@@ -17,7 +28,15 @@ type ProjectDetailsPanelViewProps = {
|
||||
/** The project's shared configuration, shown beside its chats. */
|
||||
export const ProjectDetailsPanelView: React.FC<
|
||||
ProjectDetailsPanelViewProps
|
||||
> = ({ instructions, error, onRetry, onEditInstructions }) => {
|
||||
> = ({
|
||||
ownerLabel,
|
||||
createdAt,
|
||||
organizationLabel,
|
||||
instructions,
|
||||
error,
|
||||
onRetry,
|
||||
onEditInstructions,
|
||||
}) => {
|
||||
const headingId = useId();
|
||||
const instructionsHeadingId = useId();
|
||||
const errorAlert = Boolean(error) && (
|
||||
@@ -40,13 +59,42 @@ export const ProjectDetailsPanelView: React.FC<
|
||||
Details
|
||||
</h2>
|
||||
<div className="rounded-lg border border-border">
|
||||
<dl className="m-0 flex flex-col gap-2 border-0 border-b border-solid border-border p-4 text-sm">
|
||||
<DetailRow label="Project owner">
|
||||
{ownerLabel ?? (
|
||||
<>
|
||||
<Skeleton className="my-1 ml-auto h-3 w-12" />
|
||||
<span className="sr-only">Loading</span>
|
||||
</>
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Created">
|
||||
<Tooltip>
|
||||
{/* A button so keyboard users can focus it to read the full time. */}
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
className="cursor-default rounded-sm border-0 bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link"
|
||||
>
|
||||
<time dateTime={createdAt}>
|
||||
{formatDateTime(createdAt, DATE_FORMAT.MEDIUM_DATE)}
|
||||
</time>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{formatDateTime(createdAt, DATE_FORMAT.FULL_DATETIME)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DetailRow>
|
||||
{organizationLabel !== undefined && (
|
||||
<DetailRow label="Organization">{organizationLabel}</DetailRow>
|
||||
)}
|
||||
</dl>
|
||||
<section
|
||||
aria-labelledby={instructionsHeadingId}
|
||||
className="flex flex-col gap-3 p-4"
|
||||
>
|
||||
<h3
|
||||
id={instructionsHeadingId}
|
||||
className="m-0 text-sm font-medium text-content-primary"
|
||||
className="m-0 text-sm font-medium text-content-secondary"
|
||||
>
|
||||
Instructions
|
||||
</h3>
|
||||
@@ -65,6 +113,7 @@ export const ProjectDetailsPanelView: React.FC<
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label="Create instructions"
|
||||
onClick={onEditInstructions}
|
||||
>
|
||||
Create
|
||||
@@ -94,3 +143,17 @@ export const ProjectDetailsPanelView: React.FC<
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type DetailRowProps = {
|
||||
readonly label: string;
|
||||
readonly children: React.ReactNode;
|
||||
};
|
||||
|
||||
const DetailRow: React.FC<DetailRowProps> = ({ label, children }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<dt className="shrink-0 text-content-secondary">{label}</dt>
|
||||
<dd className="m-0 min-w-0 break-words text-right font-mono text-content-primary">
|
||||
{children}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { EllipsisVerticalIcon } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { getErrorMessage } from "#/api/errors";
|
||||
import {
|
||||
deleteChatProject,
|
||||
updateChatProject,
|
||||
} from "#/api/queries/chatProjects";
|
||||
import { user } from "#/api/queries/users";
|
||||
import type { ChatProject } from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { DeleteDialog } from "#/components/Dialog/DeleteDialog/DeleteDialog";
|
||||
@@ -19,45 +18,23 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import { toast } from "#/components/Toaster/toast";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { draftStorageKeys } from "../AgentCreateForm";
|
||||
import { ProjectActionsMenuItems } from "../ChatsSidebar/chats/ProjectActionsMenuItems";
|
||||
import { ChatProjectDialog } from "../ChatsSidebar/dialogs/ChatProjectDialog";
|
||||
import { normalizeLocationSearch } from "../ChatsSidebar/locationSearch";
|
||||
import { ProjectMetadataBadges } from "./ProjectMetadataBadges";
|
||||
import { ProjectPageHeader } from "./ProjectPageHeader";
|
||||
|
||||
type ProjectHeaderSectionProps = {
|
||||
readonly project: ChatProject;
|
||||
};
|
||||
|
||||
/** The project page header with its metadata and edit and delete actions. */
|
||||
/** The project page header with its edit and delete actions. */
|
||||
export const ProjectHeaderSection: React.FC<ProjectHeaderSectionProps> = ({
|
||||
project,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user: me } = useAuthenticated();
|
||||
const { organizations, showOrganizations } = useDashboard();
|
||||
const isOwner = project.owner_id === me.id;
|
||||
const ownerQuery = useQuery({ ...user(project.owner_id), enabled: !isOwner });
|
||||
const ownerLabel = isOwner
|
||||
? "you"
|
||||
: ownerQuery.data
|
||||
? ownerQuery.data.name || ownerQuery.data.username
|
||||
: ownerQuery.isError
|
||||
? "Unknown"
|
||||
: undefined;
|
||||
const organization = organizations.find(
|
||||
(org) => org.id === project.organization_id,
|
||||
);
|
||||
const organizationLabel = showOrganizations
|
||||
? organization
|
||||
? organization.display_name || organization.name
|
||||
: "Unknown"
|
||||
: undefined;
|
||||
|
||||
const updateMutation = useMutation(updateChatProject(queryClient));
|
||||
// The project list refetch after a delete unmounts this page, so the
|
||||
@@ -130,13 +107,6 @@ export const ProjectHeaderSection: React.FC<ProjectHeaderSectionProps> = ({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
metadata={
|
||||
<ProjectMetadataBadges
|
||||
ownerLabel={ownerLabel}
|
||||
createdAt={project.created_at}
|
||||
organizationLabel={organizationLabel}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChatProjectDialog
|
||||
project={project}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { Skeleton } from "#/components/Skeleton/Skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { DATE_FORMAT, formatDateTime } from "#/utils/time";
|
||||
|
||||
type ProjectMetadataBadgesProps = {
|
||||
/** The owner's display label. `undefined` while it loads. */
|
||||
readonly ownerLabel: string | undefined;
|
||||
readonly createdAt: string;
|
||||
/** Omit to hide the organization badge. */
|
||||
readonly organizationLabel?: string;
|
||||
};
|
||||
|
||||
export const ProjectMetadataBadges: React.FC<ProjectMetadataBadgesProps> = ({
|
||||
ownerLabel,
|
||||
createdAt,
|
||||
organizationLabel,
|
||||
}) => (
|
||||
<ul className="m-0 flex list-none flex-wrap gap-2 p-0">
|
||||
<MetadataBadge label="Project owner">
|
||||
{ownerLabel ?? (
|
||||
<>
|
||||
<Skeleton className="h-2.5 w-12" />
|
||||
<span className="sr-only">Loading</span>
|
||||
</>
|
||||
)}
|
||||
</MetadataBadge>
|
||||
<MetadataBadge label="Created">
|
||||
<Tooltip>
|
||||
{/* A button so keyboard users can focus it to read the full time. */}
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
className="cursor-default rounded-sm border-0 bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link"
|
||||
>
|
||||
<time dateTime={createdAt}>
|
||||
{formatDateTime(createdAt, DATE_FORMAT.MEDIUM_DATE)}
|
||||
</time>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{formatDateTime(createdAt, DATE_FORMAT.FULL_DATETIME)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</MetadataBadge>
|
||||
{organizationLabel !== undefined && (
|
||||
<MetadataBadge label="Organization">{organizationLabel}</MetadataBadge>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
|
||||
type MetadataBadgeProps = {
|
||||
readonly label: string;
|
||||
readonly children: React.ReactNode;
|
||||
};
|
||||
|
||||
const MetadataBadge: React.FC<MetadataBadgeProps> = ({ label, children }) => (
|
||||
<li>
|
||||
<Badge size="sm">
|
||||
<span>{label}:</span>
|
||||
<span className="text-content-primary">{children}</span>
|
||||
</Badge>
|
||||
</li>
|
||||
);
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { chatProjectInstructionsKey } from "#/api/queries/chatProjects";
|
||||
import { chatCostTreeKey, projectChatsKey } from "#/api/queries/chats";
|
||||
import { userKey } from "#/api/queries/users";
|
||||
import type { Chat } from "#/api/typesGenerated";
|
||||
import { MockChat, mockChatCost } from "#/testHelpers/chatEntities";
|
||||
import {
|
||||
MockChatProject,
|
||||
MockChatProjectInstructions,
|
||||
MockUserMember,
|
||||
MockUserOwner,
|
||||
} from "#/testHelpers/entities";
|
||||
import {
|
||||
@@ -91,3 +93,19 @@ export const Mobile: Story = {
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Another user's project in a multi-organization deployment. */
|
||||
export const OwnedByAnotherUser: Story = {
|
||||
args: { project: { ...MockChatProject, owner_id: MockUserMember.id } },
|
||||
parameters: {
|
||||
showOrganizations: true,
|
||||
queries: [
|
||||
...chatQueries([{ ...MockChat, id: "chat-1", title: "Plan the launch" }]),
|
||||
{
|
||||
key: chatProjectInstructionsKey(MockChatProject.id),
|
||||
data: MockChatProjectInstructions,
|
||||
},
|
||||
{ key: userKey(MockUserMember.id), data: MockUserMember },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,15 +14,17 @@ export const ProjectPage: React.FC<ProjectPageProps> = ({
|
||||
}) => (
|
||||
// flex-1 and min-h-0 bound the page to the layout's height at every
|
||||
// breakpoint, so a long chat list scrolls here instead of being clipped.
|
||||
<div className="order-last flex min-h-0 flex-1 flex-col overflow-auto px-4 pt-8 sm:order-0 sm:pt-16">
|
||||
<div className="order-last flex min-h-0 flex-1 flex-col overflow-auto px-4 pt-6 sm:order-0 sm:px-[85.5px]">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 pb-8 @container">
|
||||
<ProjectHeaderSection key={project.id} project={project} />
|
||||
{children}
|
||||
{/* Columns follow the page width rather than the viewport, which
|
||||
the agents sidebar narrows. */}
|
||||
<div className="grid grid-cols-1 gap-8 @4xl:grid-cols-[minmax(0,1fr)_30rem]">
|
||||
<ProjectChatsList project={project} />
|
||||
<ProjectDetailsPanel project={project} />
|
||||
<div className="flex flex-col gap-14">
|
||||
{children}
|
||||
{/* Columns follow the page width rather than the viewport, which
|
||||
the agents sidebar narrows. */}
|
||||
<div className="grid grid-cols-1 gap-8 @4xl:grid-cols-[minmax(0,554fr)_minmax(0,310fr)]">
|
||||
<ProjectChatsList project={project} />
|
||||
<ProjectDetailsPanel project={project} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { EllipsisVerticalIcon } from "lucide-react";
|
||||
import { within } from "storybook/test";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { MockChatProject } from "#/testHelpers/entities";
|
||||
import { DATE_FORMAT, formatDateTime } from "#/utils/time";
|
||||
import { ProjectMetadataBadges } from "./ProjectMetadataBadges";
|
||||
import { ProjectPageHeader } from "./ProjectPageHeader";
|
||||
|
||||
const actions = (
|
||||
@@ -26,12 +23,6 @@ const meta: Meta<typeof ProjectPageHeader> = {
|
||||
args: {
|
||||
project: MockChatProject,
|
||||
actions,
|
||||
metadata: (
|
||||
<ProjectMetadataBadges
|
||||
ownerLabel="you"
|
||||
createdAt={MockChatProject.created_at}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,62 +46,3 @@ export const LongNameAndDescription: Story = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const OwnerLoading: Story = {
|
||||
args: {
|
||||
metadata: (
|
||||
<ProjectMetadataBadges
|
||||
ownerLabel={undefined}
|
||||
createdAt={MockChatProject.created_at}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const OtherOwner: Story = {
|
||||
args: {
|
||||
metadata: (
|
||||
<ProjectMetadataBadges
|
||||
ownerLabel="Jane Doe"
|
||||
createdAt={MockChatProject.created_at}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const OwnerUnknown: Story = {
|
||||
args: {
|
||||
metadata: (
|
||||
<ProjectMetadataBadges
|
||||
ownerLabel="Unknown"
|
||||
createdAt={MockChatProject.created_at}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOrganization: Story = {
|
||||
args: {
|
||||
metadata: (
|
||||
<ProjectMetadataBadges
|
||||
ownerLabel="you"
|
||||
createdAt={MockChatProject.created_at}
|
||||
organizationLabel="Coder"
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/** Keyboard focus on the creation date shows the full timestamp. */
|
||||
export const CreatedDateFocused: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
within(canvasElement)
|
||||
.getByRole("button", {
|
||||
name: formatDateTime(
|
||||
MockChatProject.created_at,
|
||||
DATE_FORMAT.MEDIUM_DATE,
|
||||
),
|
||||
})
|
||||
.focus();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,15 +4,13 @@ import { ChatProjectIcon } from "../ChatProjectIcon";
|
||||
type ProjectPageHeaderProps = {
|
||||
readonly project: ChatProject;
|
||||
readonly actions?: React.ReactNode;
|
||||
readonly metadata?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ProjectPageHeader: React.FC<ProjectPageHeaderProps> = ({
|
||||
project,
|
||||
actions,
|
||||
metadata,
|
||||
}) => (
|
||||
<header className="flex min-w-0 flex-col gap-1">
|
||||
<header className="mb-18 flex min-w-0 flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-solid border-border bg-surface-secondary">
|
||||
<ChatProjectIcon project={project} className="size-4" />
|
||||
@@ -24,7 +22,6 @@ export const ProjectPageHeader: React.FC<ProjectPageHeaderProps> = ({
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
{metadata}
|
||||
{project.description && (
|
||||
<p className="m-0 text-sm text-content-secondary [overflow-wrap:anywhere]">
|
||||
{project.description}
|
||||
|
||||
Reference in New Issue
Block a user