Use a task modal instead of a drawer (#103)

This commit is contained in:
Matt Rubens 2025-06-16 10:42:52 -04:00 committed by GitHub
parent 7c1abba38b
commit 849dccb3e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 329 additions and 281 deletions

View file

@ -19,17 +19,17 @@ export const Messages = ({ messages }: MessagesProps) => {
);
return (
<div className="space-y-4">
<div className="space-y-6">
{conversation.map((message) => (
<div
key={message.id}
className={cn(
'flex flex-col gap-2 rounded-lg p-3',
'flex flex-col gap-3 rounded-lg p-4',
message.role === 'user' ? 'bg-primary/10' : 'bg-secondary/10',
)}
>
<div className="flex flex-row items-center justify-between gap-1 text-xs font-medium text-muted-foreground">
<div className="flex items-center gap-1">
<div className="flex flex-row items-center justify-between gap-2 text-xs font-medium text-muted-foreground">
<div className="flex items-center gap-2">
<div>{message.name}</div>
<div>&middot;</div>
<div>{message.timestamp}</div>
@ -40,7 +40,7 @@ export const Messages = ({ messages }: MessagesProps) => {
</div>
)}
</div>
<div className="text-sm markdown-prose">
<div className="text-sm leading-relaxed markdown-prose">
<ReactMarkdown>{message.text}</ReactMarkdown>
</div>
</div>

View file

@ -1,114 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { X } from 'lucide-react';
import type { TaskWithUser } from '@/actions/analytics';
import { getMessages } from '@/actions/analytics';
import { canShareTask } from '@/actions/taskSharing';
import { useOrganizationSettings } from '@/hooks/useOrganizationSettings';
import { formatCurrency, formatNumber } from '@/lib/formatters';
import { generateFallbackTitle } from '@/lib/task-utils';
import { QueryKey } from '@/types/react-query';
import { Drawer, DrawerContent, Button } from '@/components/ui';
import { ShareButton } from '@/components/task-sharing/ShareButton';
import { Status } from './Status';
import { Messages } from './Messages';
type TaskDrawerProps = {
task: TaskWithUser;
onClose: () => void;
};
export const TaskDrawer = ({ task, onClose }: TaskDrawerProps) => {
const { data: messages } = useQuery({
queryKey: ['messages', task.taskId],
queryFn: () => getMessages(task.taskId),
});
const { data: orgSettings } = useOrganizationSettings();
const { data: sharePermission } = useQuery({
queryKey: [QueryKey.CanShareTask, task.taskId],
queryFn: () => canShareTask(task.taskId),
enabled: !!task.taskId,
});
const isTaskSharingEnabled =
orgSettings?.cloudSettings?.enableTaskSharing ?? false;
const canUserShareThisTask = sharePermission?.canShare ?? false;
return (
<Drawer open={true} onOpenChange={onClose} direction="right">
<DrawerContent className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<div className="flex justify-end gap-2 mb-4">
{isTaskSharingEnabled && canUserShareThisTask && (
<ShareButton task={task} />
)}
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="size-4" />
</Button>
</div>
<h2 className="text-foreground font-semibold line-clamp-2 mb-4">
{task.title || generateFallbackTitle(task)}
</h2>
<div className="mb-6 space-y-2">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Developer</span>
<span className="max-w-64 truncate">{task.user.name}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Provider</span>
<span className="max-w-64 truncate font-mono">
{task.provider}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Model</span>
<span className="max-w-64 truncate font-mono">
{task.model}
</span>
</div>
{task.mode && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Mode</span>
<span className="max-w-64 truncate">{task.mode}</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Tokens</span>
<span className="max-w-64 truncate font-mono">
{formatNumber(task.tokens)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Cost</span>
<span className="max-w-64 truncate font-mono">
{formatCurrency(task.cost)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Date</span>
<span className="max-w-64 truncate">
{new Date(task.timestamp * 1000).toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Status</span>
<Status completed={task.completed} />
</div>
</div>
{typeof messages !== 'undefined' && messages.length > 0 && (
<>
<h3 className="mb-2 text-lg font-medium">Conversation</h3>
<Messages messages={messages} />
</>
)}
</div>
</div>
</DrawerContent>
</Drawer>
);
};

View file

@ -0,0 +1,57 @@
import { useQuery } from '@tanstack/react-query';
import type { TaskWithUser } from '@/actions/analytics';
import { getMessages } from '@/actions/analytics';
import { canShareTask } from '@/actions/taskSharing';
import { useOrganizationSettings } from '@/hooks/useOrganizationSettings';
import { QueryKey } from '@/types/react-query';
import { Dialog, DialogContentLarge } from '@/components/ui';
import { ShareButton } from '@/components/task-sharing/ShareButton';
import { TaskDetails } from '@/components/task-sharing/TaskDetails';
type TaskModalProps = {
task: TaskWithUser;
open: boolean;
onClose: () => void;
};
export const TaskModal = ({ task, open, onClose }: TaskModalProps) => {
const { data: messages = [] } = useQuery({
queryKey: ['messages', task.taskId],
queryFn: () => getMessages(task.taskId),
enabled: open && !!task.taskId,
});
const { data: orgSettings } = useOrganizationSettings();
const { data: sharePermission } = useQuery({
queryKey: [QueryKey.CanShareTask, task.taskId],
queryFn: () => canShareTask(task.taskId),
enabled: open && !!task.taskId,
});
const isTaskSharingEnabled =
orgSettings?.cloudSettings?.enableTaskSharing ?? false;
const canUserShareThisTask = sharePermission?.canShare ?? false;
const headerActions = (
<>
{isTaskSharingEnabled && canUserShareThisTask && (
<ShareButton task={task} />
)}
</>
);
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContentLarge>
<TaskDetails
task={task}
messages={messages}
headerActions={headerActions}
/>
</DialogContentLarge>
</Dialog>
);
};

View file

@ -12,7 +12,7 @@ import { type Filter, type ViewMode, viewModes } from './types';
import { Developers } from './Developers';
import { Models } from './Models';
import { Tasks } from './Tasks';
import { TaskDrawer } from './TaskDrawer';
import { TaskModal } from './TaskModal';
type UsageProps = {
userRole?: 'admin' | 'member';
@ -89,7 +89,9 @@ export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => {
<Models onFilter={onFilter} />
)}
</div>
{task && <TaskDrawer task={task} onClose={() => setTask(null)} />}
{task && (
<TaskModal task={task} open={!!task} onClose={() => setTask(null)} />
)}
</>
);
};

View file

@ -1,10 +1,6 @@
import type { TaskWithUser, Message } from '@/actions/analytics';
import type { SharedByUser } from '@/types/task-sharing';
import { formatCurrency, formatNumber } from '@/lib/formatters';
import { generateFallbackTitle } from '@/lib/task-utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
import { Status } from '@/app/(authenticated)/usage/Status';
import { Messages } from '@/app/(authenticated)/usage/Messages';
import { TaskDetails } from './TaskDetails';
type SharedTaskViewProps = {
task: TaskWithUser;
@ -19,75 +15,13 @@ export const SharedTaskView = ({
sharedBy,
sharedAt,
}: SharedTaskViewProps) => {
const taskTitle = task.title || generateFallbackTitle(task);
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Task Header */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-xl line-clamp-2 leading-tight">
{taskTitle}
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Shared by {sharedBy.name} {sharedAt.toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
<Status completed={task.completed} />
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
Shared
</span>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Developer</p>
<p className="font-mono">{task.user.name}</p>
</div>
<div>
<p className="text-muted-foreground">Model</p>
<p className="font-mono">{task.model}</p>
</div>
<div>
<p className="text-muted-foreground">Provider</p>
<p className="font-mono">{task.provider}</p>
</div>
<div>
<p className="text-muted-foreground">Tokens</p>
<p className="font-mono">{formatNumber(task.tokens)}</p>
</div>
<div>
<p className="text-muted-foreground">Cost</p>
<p className="font-mono">{formatCurrency(task.cost)}</p>
</div>
</div>
</CardContent>
</Card>
{/* Conversation */}
{messages.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Conversation</CardTitle>
</CardHeader>
<CardContent>
<Messages messages={messages} />
</CardContent>
</Card>
) : (
<Card>
<CardContent className="text-center py-12">
<p className="text-muted-foreground">
No conversation messages are available for this task.
</p>
</CardContent>
</Card>
)}
</div>
<TaskDetails
task={task}
messages={messages}
sharedBy={sharedBy}
sharedAt={sharedAt}
showSharedInfo={true}
/>
);
};

View file

@ -0,0 +1,116 @@
import type { TaskWithUser, Message } from '@/actions/analytics';
import type { SharedByUser } from '@/types/task-sharing';
import { formatCurrency, formatNumber } from '@/lib/formatters';
import { generateFallbackTitle } from '@/lib/task-utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
import { Status } from '@/app/(authenticated)/usage/Status';
import { Messages } from '@/app/(authenticated)/usage/Messages';
type TaskDetailsProps = {
task: TaskWithUser;
messages: Message[];
sharedBy?: SharedByUser;
sharedAt?: Date;
showSharedInfo?: boolean;
headerActions?: React.ReactNode;
};
export const TaskDetails = ({
task,
messages,
sharedBy,
sharedAt,
showSharedInfo = false,
headerActions,
}: TaskDetailsProps) => {
const taskTitle = task.title || generateFallbackTitle(task);
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Task Header */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<CardTitle className="text-xl line-clamp-2 leading-tight">
{taskTitle}
</CardTitle>
{showSharedInfo && sharedBy && sharedAt && (
<p className="text-sm text-muted-foreground mt-1">
Shared by {sharedBy.name} {sharedAt.toLocaleDateString()}
</p>
)}
</div>
<div className="flex items-center gap-2 ml-4">
<Status completed={task.completed} />
{showSharedInfo && (
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
Shared
</span>
)}
{headerActions}
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Developer</p>
<p className="font-mono truncate">{task.user.name}</p>
</div>
<div>
<p className="text-muted-foreground">Model</p>
<p className="font-mono truncate">{task.model}</p>
</div>
<div>
<p className="text-muted-foreground">Provider</p>
<p className="font-mono truncate">{task.provider}</p>
</div>
<div>
<p className="text-muted-foreground">Tokens</p>
<p className="font-mono">{formatNumber(task.tokens)}</p>
</div>
<div>
<p className="text-muted-foreground">Cost</p>
<p className="font-mono">{formatCurrency(task.cost)}</p>
</div>
</div>
{task.mode && (
<div className="mt-4 pt-4 border-t">
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">Mode</span>
<span className="font-mono">{task.mode}</span>
</div>
</div>
)}
<div className="mt-4 pt-4 border-t">
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">Date</span>
<span>{new Date(task.timestamp * 1000).toLocaleString()}</span>
</div>
</div>
</CardContent>
</Card>
{/* Conversation */}
{messages.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Conversation</CardTitle>
</CardHeader>
<CardContent>
<Messages messages={messages} />
</CardContent>
</Card>
) : (
<Card>
<CardContent className="text-center py-12">
<p className="text-muted-foreground">
No conversation messages are available for this task.
</p>
</CardContent>
</Card>
)}
</div>
);
};

View file

@ -1,2 +1,3 @@
export * from './ShareButton';
export * from './SharedTaskView';
export * from './TaskDetails';

View file

@ -1,110 +1,162 @@
"use client"
'use client';
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
)
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
);
}
function DialogContent({ className, children, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
function DialogContentLarge({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200 sm:max-w-4xl',
className,
)}
{...props}
>
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 z-10 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
<div className="flex-1 overflow-y-auto p-6">{children}</div>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
);
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
Dialog,
DialogClose,
DialogContent,
DialogContentLarge,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};