Compare commits
4 Commits
feat/usage
...
fix/kanban
| Author | SHA1 | Date | |
|---|---|---|---|
| aa78df2c2c | |||
| 1ac2c33bc2 | |||
| eb34eb8104 | |||
| 5165a30fad |
@@ -140,8 +140,8 @@ describe("KanbanPage add task flow", (): void => {
|
|||||||
const titleInput = screen.getByPlaceholderText("Task title...");
|
const titleInput = screen.getByPlaceholderText("Task title...");
|
||||||
await user.type(titleInput, createdTask.title);
|
await user.type(titleInput, createdTask.title);
|
||||||
|
|
||||||
// Click the Add button
|
// Press Enter to submit
|
||||||
await user.click(screen.getByRole("button", { name: /✓ Add/i }));
|
await user.keyboard("{Enter}");
|
||||||
|
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(mockCreateTask).toHaveBeenCalledWith(
|
expect(mockCreateTask).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -184,10 +184,11 @@ function TaskCard({ task, provided, snapshot, columnAccent }: TaskCardProps): Re
|
|||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
config: ColumnConfig;
|
config: ColumnConfig;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
onAddTask: (status: TaskStatus, title: string) => Promise<void>;
|
onAddTask: (status: TaskStatus, title: string, projectId?: string) => Promise<void>;
|
||||||
|
projectId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanColumn({ config, tasks, onAddTask }: KanbanColumnProps): ReactElement {
|
function KanbanColumn({ config, tasks, onAddTask, projectId }: KanbanColumnProps): ReactElement {
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
@@ -208,7 +209,7 @@ function KanbanColumn({ config, tasks, onAddTask }: KanbanColumnProps): ReactEle
|
|||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await onAddTask(config.status, inputValue.trim());
|
await onAddTask(config.status, inputValue.trim(), projectId);
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -362,6 +363,45 @@ function KanbanColumn({ config, tasks, onAddTask }: KanbanColumnProps): ReactEle
|
|||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !inputValue.trim()}
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
border: "1px solid var(--primary)",
|
||||||
|
background: "var(--primary)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: isSubmitting || !inputValue.trim() ? "not-allowed" : "pointer",
|
||||||
|
opacity: isSubmitting || !inputValue.trim() ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓ Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAddForm(false);
|
||||||
|
setInputValue("");
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
cursor: isSubmitting ? "not-allowed" : "pointer",
|
||||||
|
opacity: isSubmitting ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div style={{ marginTop: 6, fontSize: "0.75rem", color: "var(--muted)" }}>
|
<div style={{ marginTop: 6, fontSize: "0.75rem", color: "var(--muted)" }}>
|
||||||
Press{" "}
|
Press{" "}
|
||||||
<kbd
|
<kbd
|
||||||
@@ -745,10 +785,17 @@ export default function KanbanPage(): ReactElement {
|
|||||||
/* --- add task handler --- */
|
/* --- add task handler --- */
|
||||||
|
|
||||||
const handleAddTask = useCallback(
|
const handleAddTask = useCallback(
|
||||||
async (status: TaskStatus, title: string) => {
|
async (status: TaskStatus, title: string, projectId?: string) => {
|
||||||
try {
|
try {
|
||||||
const wsId = workspaceId ?? undefined;
|
const wsId = workspaceId ?? undefined;
|
||||||
const newTask = await createTask({ title, status }, wsId);
|
const taskData: { title: string; status: TaskStatus; projectId?: string } = {
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
if (projectId) {
|
||||||
|
taskData.projectId = projectId;
|
||||||
|
}
|
||||||
|
const newTask = await createTask(taskData, wsId);
|
||||||
// Optimistically add to local state
|
// Optimistically add to local state
|
||||||
setTasks((prev) => [...prev, newTask]);
|
setTasks((prev) => [...prev, newTask]);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -866,23 +913,8 @@ export default function KanbanPage(): ReactElement {
|
|||||||
Clear filters
|
Clear filters
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : tasks.length === 0 ? (
|
|
||||||
/* Empty state */
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
padding: 48,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ color: "var(--muted)", margin: 0, fontSize: "0.9rem" }}>
|
|
||||||
No tasks yet. Create some tasks to see them here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
/* Board */
|
/* Board (always render columns to allow adding first task) */
|
||||||
<DragDropContext onDragEnd={handleDragEnd}>
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -899,6 +931,7 @@ export default function KanbanPage(): ReactElement {
|
|||||||
config={col}
|
config={col}
|
||||||
tasks={grouped[col.status]}
|
tasks={grouped[col.status]}
|
||||||
onAddTask={handleAddTask}
|
onAddTask={handleAddTask}
|
||||||
|
projectId={filterProject}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
import { UsageWidget } from "@/components/ui/UsageWidget";
|
||||||
import { useSidebar } from "./SidebarContext";
|
import { useSidebar } from "./SidebarContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -350,6 +351,9 @@ export function AppHeader(): React.JSX.Element {
|
|||||||
{/* Theme Toggle */}
|
{/* Theme Toggle */}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
|
{/* Usage Widget */}
|
||||||
|
<UsageWidget />
|
||||||
|
|
||||||
{/* User Avatar + Dropdown */}
|
{/* User Avatar + Dropdown */}
|
||||||
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
|
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
337
apps/web/src/components/ui/UsageWidget.tsx
Normal file
337
apps/web/src/components/ui/UsageWidget.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { fetchUsageSummary, type UsageSummary } from "@/lib/api/telemetry";
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface UsageTier {
|
||||||
|
name: string;
|
||||||
|
tokens: number;
|
||||||
|
limit: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getUsageColor(percentage: number): string {
|
||||||
|
if (percentage < 60) return "var(--success)";
|
||||||
|
if (percentage < 80) return "var(--warn)";
|
||||||
|
return "var(--danger)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(value: number): string {
|
||||||
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
||||||
|
return value.toFixed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function UsageWidget(): React.JSX.Element {
|
||||||
|
const [summary, setSummary] = useState<UsageSummary | null>(null);
|
||||||
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const tiers: UsageTier[] = summary
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: "Session",
|
||||||
|
tokens: summary.totalTokens,
|
||||||
|
limit: 100_000,
|
||||||
|
percentage: (summary.totalTokens / 100_000) * 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Daily",
|
||||||
|
tokens: summary.totalTokens,
|
||||||
|
limit: 500_000,
|
||||||
|
percentage: (summary.totalTokens / 500_000) * 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Monthly",
|
||||||
|
tokens: summary.totalTokens,
|
||||||
|
limit: 2_000_000,
|
||||||
|
percentage: (summary.totalTokens / 2_000_000) * 100,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const currentTier = tiers[0];
|
||||||
|
const usageColor = currentTier ? getUsageColor(currentTier.percentage) : "var(--muted)";
|
||||||
|
|
||||||
|
const loadSummary = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchUsageSummary("30d");
|
||||||
|
setSummary(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load usage summary:", err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSummary();
|
||||||
|
}, [loadSummary]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent): void {
|
||||||
|
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||||
|
setPopoverOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!popoverOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return (): void => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [popoverOpen]);
|
||||||
|
|
||||||
|
const pct = currentTier ? Math.min(currentTier.percentage, 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={popoverRef} style={{ position: "relative" }}>
|
||||||
|
<button
|
||||||
|
onClick={(): void => {
|
||||||
|
setPopoverOpen((prev) => !prev);
|
||||||
|
}}
|
||||||
|
aria-label="Usage widget"
|
||||||
|
aria-expanded={popoverOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
className="hidden lg:flex items-center"
|
||||||
|
style={{
|
||||||
|
gap: 6,
|
||||||
|
padding: "5px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: `1px solid ${popoverOpen ? usageColor : "var(--border)"}`,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "border-color 0.15s, color 0.15s",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.borderColor = usageColor;
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
if (!popoverOpen) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.borderColor = "var(--border)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
style={{ color: usageColor, flexShrink: 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M9 1L3 9h5l-1 6 6-8H8l1-6z" />
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontWeight: 500, color: "var(--text-2)" }}>
|
||||||
|
{isLoading ? "..." : summary ? formatTokens(summary.totalTokens) : "0"}
|
||||||
|
</span>
|
||||||
|
{!isLoading && currentTier && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: "var(--bg-mid)",
|
||||||
|
overflow: "hidden",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${String(pct)}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: usageColor,
|
||||||
|
borderRadius: 2,
|
||||||
|
transition: "width 0.3s ease-out",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && currentTier && (
|
||||||
|
<span style={{ fontWeight: 600, color: usageColor, minWidth: 32, textAlign: "right" }}>
|
||||||
|
{Math.round(currentTier.percentage)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{popoverOpen && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Usage details"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "calc(100% + 8px)",
|
||||||
|
right: 0,
|
||||||
|
width: 280,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
|
||||||
|
zIndex: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text)",
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingBottom: 8,
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Token Usage
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "20px 0",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading usage data…
|
||||||
|
</div>
|
||||||
|
) : summary ? (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 12, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem" }}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--muted)" }}>Total Tokens</span>
|
||||||
|
<span style={{ color: "var(--text)", fontFamily: "var(--mono)" }}>
|
||||||
|
{formatTokens(summary.totalTokens)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem" }}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--muted)" }}>Estimated Cost</span>
|
||||||
|
<span style={{ color: "var(--text)", fontFamily: "var(--mono)" }}>
|
||||||
|
${summary.totalCost.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem" }}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--muted)" }}>Tasks</span>
|
||||||
|
<span style={{ color: "var(--text)", fontFamily: "var(--mono)" }}>
|
||||||
|
{summary.taskCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{tiers.map((tier) => {
|
||||||
|
const tierPct = Math.min(tier.percentage, 100);
|
||||||
|
return (
|
||||||
|
<div key={tier.name}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--text-2)" }}>{tier.name}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: getUsageColor(tier.percentage),
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTokens(tier.tokens)} / {formatTokens(tier.limit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "var(--bg-mid)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${String(tierPct)}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: getUsageColor(tier.percentage),
|
||||||
|
borderRadius: 3,
|
||||||
|
transition: "width 0.3s ease-out",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/usage"
|
||||||
|
onClick={(): void => {
|
||||||
|
setPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginTop: 12,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--primary)",
|
||||||
|
textDecoration: "none",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
(e.currentTarget as HTMLAnchorElement).style.textDecoration = "underline";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
(e.currentTarget as HTMLAnchorElement).style.textDecoration = "none";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View detailed usage →
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "20px 0",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No usage data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user