mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Show tasks in more of a card view (#56)
* Show tasks in more of a card view * PR feedback
This commit is contained in:
parent
d920576ae8
commit
c002decfe5
7 changed files with 181 additions and 113 deletions
|
|
@ -8,7 +8,7 @@ import {
|
|||
} from '@roo-code/types';
|
||||
|
||||
import type { TimePeriod } from '@/types';
|
||||
import { type Task, taskSchema } from '@/types/analytics';
|
||||
import { taskSchema } from '@/types/analytics';
|
||||
import { analytics } from '@/lib/server';
|
||||
import { type User, getUsersById } from '@/db/server';
|
||||
|
||||
|
|
@ -225,30 +225,48 @@ export const getModelUsage = async ({
|
|||
* getTasks
|
||||
*/
|
||||
|
||||
const taskWithTitleSchema = taskSchema.extend({
|
||||
title: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type TaskWithTitle = z.infer<typeof taskWithTitleSchema>;
|
||||
|
||||
export type TaskWithUser = TaskWithTitle & { user: User };
|
||||
|
||||
export const getTasks = async ({
|
||||
orgId,
|
||||
}: {
|
||||
orgId?: string | null;
|
||||
}): Promise<(Task & { user: User })[]> => {
|
||||
}): Promise<TaskWithUser[]> => {
|
||||
if (!orgId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await analytics.query({
|
||||
query: `
|
||||
WITH first_messages AS (
|
||||
SELECT
|
||||
taskId,
|
||||
argMin(text, ts) as title
|
||||
FROM messages
|
||||
WHERE orgId = {orgId: String}
|
||||
GROUP BY taskId
|
||||
)
|
||||
SELECT
|
||||
taskId,
|
||||
userId,
|
||||
apiProvider AS provider,
|
||||
modelId as model,
|
||||
MAX(CASE WHEN type = 'Task Completed' THEN 1 ELSE 0 END) AS completed,
|
||||
SUM(CASE WHEN type = 'LLM Completion' THEN COALESCE(inputTokens, 0) + COALESCE(outputTokens, 0) ELSE 0 END) AS tokens,
|
||||
SUM(CASE WHEN type = 'LLM Completion' THEN COALESCE(cost, 0) ELSE 0 END) AS cost,
|
||||
MIN(timestamp) AS timestamp
|
||||
FROM events
|
||||
e.taskId,
|
||||
e.userId,
|
||||
e.apiProvider AS provider,
|
||||
e.modelId as model,
|
||||
MAX(CASE WHEN e.type = 'Task Completed' THEN 1 ELSE 0 END) AS completed,
|
||||
SUM(CASE WHEN e.type = 'LLM Completion' THEN COALESCE(e.inputTokens, 0) + COALESCE(e.outputTokens, 0) ELSE 0 END) AS tokens,
|
||||
SUM(CASE WHEN e.type = 'LLM Completion' THEN COALESCE(e.cost, 0) ELSE 0 END) AS cost,
|
||||
MIN(e.timestamp) AS timestamp,
|
||||
any(fm.title) AS title
|
||||
FROM events e
|
||||
LEFT JOIN first_messages fm ON e.taskId = fm.taskId
|
||||
WHERE
|
||||
orgId = {orgId: String}
|
||||
AND type IN ({types: Array(String)})
|
||||
e.orgId = {orgId: String}
|
||||
AND e.type IN ({types: Array(String)})
|
||||
GROUP BY 1, 2, 3, 4
|
||||
ORDER BY timestamp DESC
|
||||
`,
|
||||
|
|
@ -263,11 +281,11 @@ export const getTasks = async ({
|
|||
},
|
||||
});
|
||||
|
||||
const tasks = z.array(taskSchema).parse(await results.json());
|
||||
const tasks = z.array(taskWithTitleSchema).parse(await results.json());
|
||||
|
||||
const users = await getUsersById(tasks.map(({ userId }) => userId));
|
||||
|
||||
return tasks
|
||||
.map((usage) => ({ ...usage, user: users[usage.userId] }))
|
||||
.filter((usage): usage is Task & { user: User } => !!usage.user);
|
||||
.filter((usage): usage is TaskWithUser => !!usage.user);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import type { User } from '@/db/server';
|
||||
import type { Task } from '@/types/analytics';
|
||||
import type { TaskWithUser } from '@/actions/analytics';
|
||||
import { getMessages } from '@/actions/analytics';
|
||||
import { formatCurrency, formatNumber } from '@/lib/formatters';
|
||||
import { generateFallbackTitle } from '@/lib/taskUtils';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
|
|
@ -18,7 +18,7 @@ import { Status } from './Status';
|
|||
import { Messages } from './Messages';
|
||||
|
||||
type TaskDrawerProps = {
|
||||
task: Task & { user: User };
|
||||
task: TaskWithUser;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ export const TaskDrawer = ({ task, onClose }: TaskDrawerProps) => {
|
|||
<Drawer open={true} onOpenChange={onClose} direction="right">
|
||||
<DrawerContent className="flex flex-col h-full">
|
||||
<DrawerHeader className="flex-shrink-0">
|
||||
<DrawerTitle>Task</DrawerTitle>
|
||||
<DrawerTitle>{task.title || generateFallbackTitle(task)}</DrawerTitle>
|
||||
<DrawerDescription>{task.taskId}</DrawerDescription>
|
||||
<div className="absolute top-2 right-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { useAuth } from '@clerk/nextjs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { User } from '@/db/server';
|
||||
import type { Task } from '@/types/analytics';
|
||||
import type { TaskWithUser } from '@/actions/analytics';
|
||||
import { getTasks } from '@/actions/analytics';
|
||||
import { formatNumber, formatCurrency } from '@/lib/formatters';
|
||||
import { Button, Skeleton } from '@/components/ui';
|
||||
import { DataTable } from '@/components/layout';
|
||||
import { Skeleton } from '@/components/ui';
|
||||
import { TaskCard } from '@/components/usage';
|
||||
|
||||
import type { Filter } from './types';
|
||||
import { Status } from './Status';
|
||||
|
||||
export const Tasks = ({
|
||||
filter,
|
||||
|
|
@ -20,11 +16,11 @@ export const Tasks = ({
|
|||
}: {
|
||||
filter: Filter | null;
|
||||
onFilter: (filter: Filter) => void;
|
||||
onTaskSelected: (task: Task & { user: User }) => void;
|
||||
onTaskSelected: (task: TaskWithUser) => void;
|
||||
}) => {
|
||||
const { orgId } = useAuth();
|
||||
|
||||
const { data = Array(3).fill({}), isPending } = useQuery({
|
||||
const { data = [], isPending } = useQuery({
|
||||
queryKey: ['getTasks', orgId],
|
||||
queryFn: () => getTasks({ orgId }),
|
||||
enabled: !!orgId,
|
||||
|
|
@ -42,87 +38,36 @@ export const Tasks = ({
|
|||
);
|
||||
}, [filter, data]);
|
||||
|
||||
const cols: ColumnDef<Task & { user: User }>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'Task ID',
|
||||
cell: ({ row: { original: task } }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => onTaskSelected(task)}
|
||||
className="px-0"
|
||||
>
|
||||
{task.taskId.slice(0, 8)}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Developer',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() =>
|
||||
onFilter({
|
||||
type: 'userId',
|
||||
value: row.original.userId,
|
||||
label: row.original.user.name,
|
||||
})
|
||||
}
|
||||
className="px-0"
|
||||
>
|
||||
{row.original.user.name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Model',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() =>
|
||||
onFilter({
|
||||
type: 'model',
|
||||
value: row.original.model,
|
||||
label: row.original.model,
|
||||
})
|
||||
}
|
||||
className="px-0"
|
||||
>
|
||||
{row.original.model}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Tokens',
|
||||
cell: ({ row }) => formatNumber(row.original.tokens),
|
||||
},
|
||||
{
|
||||
header: 'Cost (USD)',
|
||||
cell: ({ row }) => formatCurrency(row.original.cost),
|
||||
},
|
||||
{
|
||||
header: 'Date',
|
||||
cell: ({ row }) =>
|
||||
new Date(1000 * row.original.timestamp).toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <Status completed={row.original.completed} />,
|
||||
},
|
||||
],
|
||||
[onFilter, onTaskSelected],
|
||||
);
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array(8)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
isPending
|
||||
? cols.map((col) => ({
|
||||
...col,
|
||||
cell: () => <Skeleton className="h-9 w-full" />,
|
||||
}))
|
||||
: cols,
|
||||
[isPending, cols],
|
||||
);
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No tasks found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DataTable columns={columns} data={tasks} />;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.taskId}
|
||||
task={task}
|
||||
onFilter={onFilter}
|
||||
onTaskSelected={onTaskSelected}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import { useState, useCallback } from 'react';
|
|||
import { useTranslations } from 'next-intl';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import type { User } from '@/db/server';
|
||||
import type { Task } from '@/types/analytics';
|
||||
import type { TaskWithUser } from '@/actions/analytics';
|
||||
import { Badge, Button } from '@/components/ui';
|
||||
import { UsageCard } from '@/components/usage';
|
||||
|
||||
|
|
@ -19,7 +18,7 @@ export const Usage = () => {
|
|||
const t = useTranslations('Analytics');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('tasks');
|
||||
const [filter, setFilter] = useState<Filter | null>(null);
|
||||
const [task, setTask] = useState<(Task & { user: User }) | null>(null);
|
||||
const [task, setTask] = useState<TaskWithUser | null>(null);
|
||||
|
||||
const onFilter = useCallback((filter: Filter) => {
|
||||
setFilter(filter);
|
||||
|
|
@ -62,7 +61,7 @@ export const Usage = () => {
|
|||
<Tasks
|
||||
filter={filter}
|
||||
onFilter={onFilter}
|
||||
onTaskSelected={(task: Task & { user: User }) => setTask(task)}
|
||||
onTaskSelected={(task: TaskWithUser) => setTask(task)}
|
||||
/>
|
||||
) : viewMode === 'developers' ? (
|
||||
<Developers onFilter={onFilter} />
|
||||
|
|
|
|||
83
src/components/usage/TaskCard.tsx
Normal file
83
src/components/usage/TaskCard.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import {
|
||||
formatNumber,
|
||||
formatCurrency,
|
||||
formatTimestamp,
|
||||
} from '@/lib/formatters';
|
||||
import { generateFallbackTitle } from '@/lib/taskUtils';
|
||||
import { Card, CardContent, Button } from '@/components/ui';
|
||||
|
||||
import type { TaskWithUser } from '@/actions/analytics';
|
||||
import type { Filter } from '@/app/(authenticated)/usage/types';
|
||||
import { Status } from '@/app/(authenticated)/usage/Status';
|
||||
|
||||
type TaskCardProps = {
|
||||
task: TaskWithUser;
|
||||
onFilter: (filter: Filter) => void;
|
||||
onTaskSelected: (task: TaskWithUser) => void;
|
||||
};
|
||||
|
||||
export const TaskCard = ({ task, onFilter, onTaskSelected }: TaskCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-md transition-shadow py-0"
|
||||
onClick={() => onTaskSelected(task)}
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
<div className="px-4 py-2">
|
||||
{/* First line - Title and Status */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<h3 className="font-medium text-sm truncate flex-1 leading-none">
|
||||
{task.title || generateFallbackTitle(task)}
|
||||
</h3>
|
||||
<Status completed={task.completed} />
|
||||
</div>
|
||||
|
||||
{/* Second line - All metadata */}
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{formatTimestamp(task.timestamp)}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFilter({
|
||||
type: 'userId',
|
||||
value: task.userId,
|
||||
label: task.user.name,
|
||||
});
|
||||
}}
|
||||
className="px-0 h-auto text-xs font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{task.user.name}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFilter({
|
||||
type: 'model',
|
||||
value: task.model,
|
||||
label: task.model,
|
||||
});
|
||||
}}
|
||||
className="px-0 h-auto text-xs font-normal font-mono text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{task.model}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Metrics on the right */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">
|
||||
{formatNumber(task.tokens)} tokens
|
||||
</span>
|
||||
<span className="font-medium">{formatCurrency(task.cost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -1 +1,2 @@
|
|||
export { UsageCard } from './UsageCard';
|
||||
export { TaskCard } from './TaskCard';
|
||||
|
|
|
|||
22
src/lib/taskUtils.ts
Normal file
22
src/lib/taskUtils.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { format, getHours } from 'date-fns';
|
||||
import type { TaskWithUser } from '@/actions/analytics';
|
||||
|
||||
export const generateFallbackTitle = (task: TaskWithUser) => {
|
||||
const date = new Date(task.timestamp * 1000);
|
||||
const dayOfWeek = format(date, 'EEEE'); // Full day name (e.g., 'Monday')
|
||||
const hour = getHours(date);
|
||||
|
||||
// Determine time of day
|
||||
let timeOfDay;
|
||||
if (hour >= 5 && hour < 12) {
|
||||
timeOfDay = 'morning';
|
||||
} else if (hour >= 12 && hour < 17) {
|
||||
timeOfDay = 'afternoon';
|
||||
} else if (hour >= 17 && hour < 21) {
|
||||
timeOfDay = 'evening';
|
||||
} else {
|
||||
timeOfDay = 'night';
|
||||
}
|
||||
|
||||
return `${dayOfWeek} ${timeOfDay} task`;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue