diff --git a/src/client/App.test.tsx b/src/client/App.test.tsx index bc027af..0506c37 100644 --- a/src/client/App.test.tsx +++ b/src/client/App.test.tsx @@ -85,9 +85,11 @@ describe("App task detail routing", () => { await renderApp(); await waitFor(() => - expect(document.body.querySelector('button[aria-label="Open task Route task"]')).toBeTruthy(), + expect( + document.body.querySelector('[role="button"][aria-label="Open task Route task"]'), + ).toBeTruthy(), ); - await clickSelector('button[aria-label="Open task Route task"]'); + await clickSelector('[role="button"][aria-label="Open task Route task"]'); expect(window.location.search).toBe("?task=task-1"); await waitFor(() => expect(api.getTask).toHaveBeenCalledWith("task-1")); @@ -118,14 +120,14 @@ async function clickSelector(selector: string) { async function waitFor(assertion: () => void) { let lastError: unknown; - for (let index = 0; index < 40; index += 1) { + for (let index = 0; index < 120; index += 1) { try { assertion(); return; } catch (err) { lastError = err; await act(async () => { - await new Promise((resolve) => window.setTimeout(resolve, 0)); + await new Promise((resolve) => window.setTimeout(resolve, 10)); }); } } diff --git a/src/client/App.tsx b/src/client/App.tsx index 4ebfa24..e21c4cd 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,11 +1,22 @@ import { AlertCircle, + Filter, LoaderCircle, Monitor, Plus, Search, + X, } from "lucide-react"; -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from "react"; import type { AppSettings, AssistantChatConversation, @@ -27,16 +38,22 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, + CardAction, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; -import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; -import { Kbd } from "@/components/ui/kbd"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { Skeleton } from "@/components/ui/skeleton"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; import { api } from "./api"; import { preserveTransientProposedActions, @@ -63,6 +80,7 @@ const TaskModal = lazy(() => type View = "board" | "settings"; type WorkspaceMode = "board" | "chat"; type HistoryWriteMode = "push" | "replace"; +type BoardScope = "all" | "ready_now" | "running" | "needs_attention" | "draft"; const VIEW_META: Record = { board: { @@ -105,6 +123,8 @@ export function App() { const [settings, setSettings] = useState(null); const [providerStatuses, setProviderStatuses] = useState([]); const [query, setQuery] = useState(""); + const [activeFocusAreaId, setActiveFocusAreaId] = useState(null); + const [boardScope, setBoardScope] = useState("all"); const [view, setView] = useState("board"); const [workspaceMode, setWorkspaceMode] = useState("board"); const [chatConversations, setChatConversations] = useState([]); @@ -228,27 +248,31 @@ export function App() { }; }, [editingTask, tasks]); - const filteredTasks = useMemo(() => { + const searchAndFocusTasks = useMemo(() => { const needle = query.trim().toLowerCase(); const focusAreaById = new Map( (settings?.userProfile.focusAreas ?? []).map((area) => [area.id, area.label]), ); - if (!needle) { - return tasks; - } return tasks.filter((task) => - [ - task.title, - task.description, - task.status, - task.priority, - task.focusAreaId ? focusAreaById.get(task.focusAreaId) ?? "" : "", - ] - .join(" ") - .toLowerCase() - .includes(needle), + (activeFocusAreaId === null || task.focusAreaId === activeFocusAreaId) && + (!needle || + [ + task.title, + task.description, + task.status, + task.priority, + task.focusAreaId ? focusAreaById.get(task.focusAreaId) ?? "" : "", + ] + .join(" ") + .toLowerCase() + .includes(needle)), ); - }, [settings, tasks, query]); + }, [activeFocusAreaId, settings, tasks, query]); + + const filteredTasks = useMemo( + () => applyBoardScope(searchAndFocusTasks, boardScope), + [boardScope, searchAndFocusTasks], + ); const counts = useMemo( () => @@ -261,6 +285,21 @@ export function App() { [tasks], ); + const visibleCounts = useMemo( + () => countTasksByStatus(searchAndFocusTasks), + [searchAndFocusTasks], + ); + + const focusAreaCounts = useMemo(() => { + const next: Record = {}; + for (const task of tasks) { + if (task.focusAreaId) { + next[task.focusAreaId] = (next[task.focusAreaId] ?? 0) + 1; + } + } + return next; + }, [tasks]); + async function createOrUpdateTask(input: TaskCreateInput) { if (editingTask) { const result = await api.updateTask(editingTask.id, input); @@ -469,8 +508,15 @@ export function App() { } } - const readyNowCount = counts.ready + counts.in_progress; - const attentionCount = counts.needs_attention; + function clearBoardFilters() { + setQuery(""); + setActiveFocusAreaId(null); + setBoardScope("all"); + } + + const runningCount = searchAndFocusTasks.filter( + (task) => task.execution?.status === "queued" || task.execution?.status === "running", + ).length; const activeView = VIEW_META[view]; const activeDescription = workspaceMode === "chat" @@ -480,6 +526,30 @@ export function App() { providerStatuses.find((provider) => provider.provider === "openai")?.configured, ); const focusAreas: FocusArea[] = settings?.userProfile.focusAreas ?? []; + const activeFocusArea = activeFocusAreaId + ? focusAreas.find((area) => area.id === activeFocusAreaId) ?? null + : null; + const hasActiveBoardFilters = + query.trim().length > 0 || activeFocusAreaId !== null || boardScope !== "all"; + const boardHeaderDescription = formatBoardStateLine({ + activeFocusArea, + boardScope, + filteredCount: filteredTasks.length, + query, + runningCount, + visibleCounts, + }); + const visibleStatuses = useMemo( + () => getVisibleStatuses(boardScope, filteredTasks), + [boardScope, filteredTasks], + ); + + useEffect(() => { + if (activeFocusAreaId && !focusAreas.some((area) => area.id === activeFocusAreaId)) { + setActiveFocusAreaId(null); + } + }, [activeFocusAreaId, focusAreas]); + const activeConversation = chatConversations.find((conversation) => conversation.id === activeConversationId) ?? null; const createInitialTask = @@ -507,15 +577,18 @@ export function App() { workspaceMode={workspaceMode} counts={counts} focusAreas={focusAreas} + focusAreaCounts={focusAreaCounts} + activeFocusAreaId={activeFocusAreaId} chatConversations={chatConversations} activeConversationId={activeConversationId} chatHistoryLoading={workspaceMode === "chat" && chatHistoryLoading} onViewChange={changeView} onWorkspaceModeChange={changeWorkspaceMode} + onFocusAreaChange={setActiveFocusAreaId} onChatConversationSelect={selectChatConversation} onNewChatConversation={() => void createChatConversation()} /> - +
@@ -530,7 +603,9 @@ export function App() {

- {activeDescription} + {workspaceMode === "chat" || view === "settings" + ? activeDescription + : boardHeaderDescription}

@@ -543,12 +618,27 @@ export function App() { setQuery(event.target.value)} - placeholder="Search tasks" + placeholder="Search title, description, focus, priority" + aria-label="Search tasks" /> - - ⌘K - + {query && ( + + setQuery("")} + > + + + + )} + {hasActiveBoardFilters && ( + + )} + )} + + + + + + ); + } + + const metrics = ([ + { + label: "Ready now", + value: readyNowCount, + detail: "Ready or in progress", + scope: "ready_now", + }, + { + label: "Running", + value: props.runningCount, + detail: "Queued or running", + scope: "running", + }, + { + label: "Needs attention", + value: props.counts.needs_attention, + detail: "Blocked or failed", + scope: "needs_attention", + }, + { + label: "Draft ideas", + value: props.counts.draft, + detail: "Waiting to refine", + scope: "draft", + }, + ] satisfies Array<{ + label: string; + value: number; + detail: string; + scope: BoardScope; + }>).filter((metric) => metric.value > 0 || props.activeScope === metric.scope); + return ( - - +
+
+ {metrics.map((metric) => ( + props.onScopeChange(metric.scope)} + /> + ))} +
+ {props.activeScope !== "all" && ( + + )} +
+ ); +} + +function BoardMetricCard(props: { + label: string; + value: number; + detail: string; + active: boolean; + onActivate: () => void; +}) { + return ( + activateOnKeyboard(event, props.onActivate)} + > + {props.label} {props.value} {props.detail} @@ -742,3 +974,97 @@ function SummaryCard(props: { label: string; value: number; detail: string }) { ); } + +function activateOnKeyboard(event: KeyboardEvent, onActivate: () => void) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onActivate(); + } +} + +function applyBoardScope(tasks: Task[], scope: BoardScope): Task[] { + if (scope === "ready_now") { + return tasks.filter((task) => task.status === "ready" || task.status === "in_progress"); + } + if (scope === "running") { + return tasks.filter( + (task) => task.execution?.status === "queued" || task.execution?.status === "running", + ); + } + if (scope === "needs_attention") { + return tasks.filter((task) => task.status === "needs_attention"); + } + if (scope === "draft") { + return tasks.filter((task) => task.status === "draft"); + } + return tasks; +} + +function countTasksByStatus(tasks: Task[]): Record { + return Object.fromEntries( + TASK_STATUSES.map((status) => [ + status, + tasks.filter((task) => task.status === status).length, + ]), + ) as Record; +} + +function getVisibleStatuses(scope: BoardScope, tasks: Task[]): TaskStatus[] { + if (scope === "ready_now") { + return ["ready", "in_progress"]; + } + if (scope === "running") { + const statuses = TASK_STATUSES.filter((status) => + tasks.some((task) => task.status === status), + ); + return statuses.length > 0 ? statuses : ["in_progress"]; + } + if (scope === "needs_attention") { + return ["needs_attention"]; + } + if (scope === "draft") { + return ["draft"]; + } + return [...TASK_STATUSES]; +} + +function formatBoardStateLine(input: { + activeFocusArea: FocusArea | null; + boardScope: BoardScope; + filteredCount: number; + query: string; + runningCount: number; + visibleCounts: Record; +}): string { + if (input.boardScope !== "all") { + return `${BOARD_SCOPE_LABELS[input.boardScope]} - ${formatTaskCount(input.filteredCount)}`; + } + if (input.query.trim()) { + return `Showing ${formatTaskCount(input.filteredCount)} for "${input.query.trim()}"`; + } + const focusPrefix = input.activeFocusArea ? `${input.activeFocusArea.label} - ` : ""; + const readyNowCount = input.visibleCounts.ready + input.visibleCounts.in_progress; + if ( + readyNowCount === 0 && + input.visibleCounts.needs_attention === 0 && + input.visibleCounts.draft === 0 + ) { + return `${focusPrefix}${input.visibleCounts.done} done - no active work`; + } + return [ + `${focusPrefix}${readyNowCount} ready now`, + `${input.runningCount} running`, + `${input.visibleCounts.needs_attention} needs attention`, + ].join(" - "); +} + +function formatTaskCount(count: number): string { + return `${count} ${count === 1 ? "task" : "tasks"}`; +} + +const BOARD_SCOPE_LABELS: Record, string> = { + ready_now: "Ready now", + running: "Running", + needs_attention: "Needs attention", + draft: "Draft ideas", +}; diff --git a/src/client/components/BoardView.test.tsx b/src/client/components/BoardView.test.tsx index e79295f..433dd05 100644 --- a/src/client/components/BoardView.test.tsx +++ b/src/client/components/BoardView.test.tsx @@ -2,7 +2,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Priority, Task, TaskExecution } from "../../shared/types"; +import type { Priority, Task, TaskExecution, TaskStatus } from "../../shared/types"; import { BoardView } from "./BoardView"; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = @@ -45,8 +45,8 @@ describe("BoardView", () => { }); const renderedTaskTitles = Array.from( - container.querySelectorAll('button[aria-label^="Open task "]'), - ).map((button) => button.getAttribute("aria-label")?.replace("Open task ", "")); + container.querySelectorAll('[role="button"][aria-label^="Open task "]'), + ).map((card) => card.getAttribute("aria-label")?.replace("Open task ", "")); expect(renderedTaskTitles).toEqual([ "High priority task A", @@ -70,12 +70,54 @@ describe("BoardView", () => { ); }); - const card = container.querySelector( - 'button[aria-label="Open task Running task"]', + const card = container.querySelector( + '[role="button"][aria-label="Open task Running task"]', ); expect(card?.textContent).toContain("Running"); }); + + it("lets mobile users stay on an empty status tab", async () => { + await act(async () => { + root.render( + , + ); + }); + + const draftTab = container.querySelector( + '[data-board-mobile-status="draft"]', + ); + const readyTab = container.querySelector( + '[data-board-mobile-status="ready"]', + ); + + expect(draftTab?.getAttribute("data-state")).toBe("active"); + + await act(async () => { + root.render( + , + ); + }); + + await waitFor(() => { + expect(draftTab?.getAttribute("data-state")).toBe("active"); + }); + expect(readyTab?.getAttribute("data-state")).toBe("inactive"); + }); }); function task( @@ -83,12 +125,13 @@ function task( title: string, priority: Priority, execution: TaskExecution | null = null, + status?: TaskStatus, ): Task { return { id, title, description: "", - status: execution?.status === "running" ? "in_progress" : "ready", + status: status ?? (execution?.status === "running" ? "in_progress" : "ready"), priority, focusAreaId: null, tags: [], @@ -99,6 +142,22 @@ function task( }; } +async function waitFor(assertion: () => void) { + let lastError: unknown; + for (let index = 0; index < 40; index += 1) { + try { + assertion(); + return; + } catch (err) { + lastError = err; + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 10)); + }); + } + } + throw lastError; +} + function execution(): TaskExecution { return { id: "execution-1", diff --git a/src/client/components/BoardView.tsx b/src/client/components/BoardView.tsx index 860dd2e..d632e96 100644 --- a/src/client/components/BoardView.tsx +++ b/src/client/components/BoardView.tsx @@ -1,9 +1,25 @@ -import { ClipboardList, Plus } from "lucide-react"; +import { + AlertTriangle, + Check, + ClipboardList, + Clock3, + LoaderCircle, + PencilLine, + Plus, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { FocusArea, Task, TaskStatus } from "../../shared/types"; import { COLUMN_LABELS, TASK_STATUSES } from "../../shared/types"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Empty, EmptyContent, @@ -13,6 +29,7 @@ import { EmptyTitle, } from "@/components/ui/empty"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import { TaskCard } from "./TaskCard"; @@ -20,114 +37,282 @@ export function BoardView(props: { tasks: Task[]; focusAreas: FocusArea[]; singleColumn?: TaskStatus; + visibleStatuses?: TaskStatus[]; onCreateTask: (status: TaskStatus) => void; onEditTask: (task: Task) => void; onMoveTask: (taskId: string, status: TaskStatus) => void; }) { - const columns = props.singleColumn ? [props.singleColumn] : TASK_STATUSES; + const columns = useMemo( + () => + props.singleColumn + ? [props.singleColumn] + : props.visibleStatuses && props.visibleStatuses.length > 0 + ? props.visibleStatuses + : [...TASK_STATUSES], + [props.singleColumn, props.visibleStatuses], + ); const focusAreaById = new Map(props.focusAreas.map((area) => [area.id, area])); + const mobileTabListRef = useRef(null); + const [mobileStatus, setMobileStatus] = useState(() => + pickBestMobileStatus(columns, props.tasks), + ); + + useEffect(() => { + if (!columns.includes(mobileStatus)) { + setMobileStatus(pickBestMobileStatus(columns, props.tasks)); + } + }, [columns, mobileStatus, props.tasks]); + + useEffect(() => { + const activeTab = mobileTabListRef.current?.querySelector( + `[data-board-mobile-status="${mobileStatus}"]`, + ); + activeTab?.scrollIntoView?.({ block: "nearest", inline: "center" }); + }, [mobileStatus]); return (
+ {!props.singleColumn && columns.length > 1 && ( + setMobileStatus(value as TaskStatus)} + className="lg:hidden" + > +
+ + {columns.map((status) => ( + + {COLUMN_LABELS[status]} + + {getTasksForStatus(props.tasks, status).length} + + + ))} + +
+ {columns.map((status) => ( + + + + ))} +
+ )} +
1 && "hidden lg:block", )} + aria-label="Kanban board" > - {columns.map((status) => { - const tasks = getTasksForStatus(props.tasks, status); - return ( - event.preventDefault()} - onDrop={(event) => { - const taskId = event.dataTransfer.getData("text/task-id"); - if (taskId) { - void props.onMoveTask(taskId, status); - } - }} - > - - - {COLUMN_LABELS[status]} - - {tasks.length} - - - - - - - - -
- {tasks.length === 0 ? ( - - - - - - No tasks here - - Drop work into this column or create a new task. - - - - - - - ) : ( - tasks.map((task) => ( - props.onEditTask(task)} - onDragStart={(event) => { - event.dataTransfer.setData("text/task-id", task.id); - event.dataTransfer.effectAllowed = "move"; - }} - /> - )) - )} -
-
- {tasks.length > 0 && ( +
+
+ {columns.map((status) => { + const tasks = getTasksForStatus(props.tasks, status); + return ( + + ); + })} +
+
+
+
+ ); +} + +function ColumnRail(props: { + status: TaskStatus; + tasks: Task[]; + focusAreaById: Map; + className?: string; + onCreateTask: (status: TaskStatus) => void; + onEditTask: (task: Task) => void; + onMoveTask: (taskId: string, status: TaskStatus) => void; +}) { + const [isDragTarget, setIsDragTarget] = useState(false); + const isDone = props.status === "done"; + const needsAttention = props.status === "needs_attention" && props.tasks.length > 0; + const [showAllDone, setShowAllDone] = useState(false); + const visibleTasks = isDone && !showAllDone ? props.tasks.slice(0, 4) : props.tasks; + const hiddenDoneCount = props.tasks.length - visibleTasks.length; + + return ( + setIsDragTarget(true)} + onDragLeave={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setIsDragTarget(false); + } + }} + onDragOver={(event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }} + onDrop={(event) => { + const taskId = event.dataTransfer.getData("text/task-id"); + setIsDragTarget(false); + if (taskId) { + void props.onMoveTask(taskId, props.status); + } + }} + > + + + + {COLUMN_LABELS[props.status]} + {props.tasks.length} + + {COLUMN_HINTS[props.status]} + + + + + + {isDragTarget && ( +
+ Drop to move to {COLUMN_LABELS[props.status]}. +
+ )} + +
+ {props.tasks.length === 0 ? ( + + + + + + {EMPTY_TITLES[props.status]} + {EMPTY_DESCRIPTIONS[props.status]} + + - )} - - - ); - })} - -
+ + + ) : ( + visibleTasks.map((task) => ( + props.onEditTask(task)} + onMoveTask={props.onMoveTask} + onDragStart={(event) => { + event.dataTransfer.setData("text/task-id", task.id); + event.dataTransfer.effectAllowed = "move"; + }} + /> + )) + )} + +
+ {hiddenDoneCount > 0 && ( + + )} + {props.tasks.length > 0 && ( + + )} +
+
); } +const COLUMN_HINTS: Record = { + draft: "Ideas to refine", + ready: "Ready to start", + in_progress: "Currently moving", + needs_attention: "Blocked or failed", + done: "Completed", +}; + +const EMPTY_TITLES: Record = { + draft: "No drafts", + ready: "Nothing ready", + in_progress: "No active work", + needs_attention: "Nothing blocked", + done: "Nothing completed", +}; + +const EMPTY_DESCRIPTIONS: Record = { + draft: "Capture a rough idea here.", + ready: "Prepared tasks will appear here.", + in_progress: "Running work will appear here.", + needs_attention: "Blocked work will appear here.", + done: "Finished tasks will appear here.", +}; + +function ColumnIcon(props: { status: TaskStatus }) { + if (props.status === "draft") { + return ; + } + if (props.status === "ready") { + return ; + } + if (props.status === "in_progress") { + return ; + } + if (props.status === "needs_attention") { + return ; + } + return ; +} + const PRIORITY_SORT_VALUE: Record = { high: 0, medium: 1, @@ -145,3 +330,20 @@ function getTasksForStatus(tasks: Task[], status: TaskStatus): Task[] { ) .map(({ task }) => task); } + +function pickBestMobileStatus(columns: TaskStatus[], tasks: Task[]): TaskStatus { + const preferredOrder: TaskStatus[] = [ + "ready", + "in_progress", + "needs_attention", + "draft", + "done", + ]; + return ( + preferredOrder.find( + (status) => columns.includes(status) && tasks.some((task) => task.status === status), + ) ?? + columns[0] ?? + "draft" + ); +} diff --git a/src/client/components/Sidebar.test.tsx b/src/client/components/Sidebar.test.tsx index 6c01e26..c9c3bd8 100644 --- a/src/client/components/Sidebar.test.tsx +++ b/src/client/components/Sidebar.test.tsx @@ -53,11 +53,14 @@ describe("Sidebar", () => { workspaceMode="chat" counts={counts()} focusAreas={focusAreas()} + focusAreaCounts={focusAreaCounts()} + activeFocusAreaId={null} chatConversations={chatConversations()} activeConversationId="conversation-1" chatHistoryLoading={false} onViewChange={onViewChange} onWorkspaceModeChange={onWorkspaceModeChange} + onFocusAreaChange={vi.fn()} onChatConversationSelect={onChatConversationSelect} onNewChatConversation={vi.fn()} /> @@ -114,11 +117,14 @@ describe("Sidebar", () => { workspaceMode="board" counts={counts()} focusAreas={focusAreas()} + focusAreaCounts={focusAreaCounts()} + activeFocusAreaId={null} chatConversations={chatConversations()} activeConversationId="conversation-1" chatHistoryLoading={false} onViewChange={onViewChange} onWorkspaceModeChange={onWorkspaceModeChange} + onFocusAreaChange={vi.fn()} onChatConversationSelect={vi.fn()} onNewChatConversation={vi.fn()} /> @@ -140,6 +146,47 @@ describe("Sidebar", () => { expect(onWorkspaceModeChange).toHaveBeenCalledWith("board"); expect(onViewChange).toHaveBeenCalledWith("board"); }); + + it("turns focus areas into clickable board filters with counts", async () => { + const onFocusAreaChange = vi.fn(); + + await act(async () => { + root.render( + + + + + , + ); + }); + + expect(container.textContent).toContain("Focus areas"); + expect(container.textContent).toContain("All work"); + expect(container.textContent).toContain("Client Ops"); + + await act(async () => { + Array.from(container.querySelectorAll('button[data-sidebar="menu-button"]')) + .find((button) => button.textContent?.includes("All work")) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onFocusAreaChange).toHaveBeenCalledWith(null); + }); }); function counts(): Record { @@ -156,6 +203,10 @@ function focusAreas(): FocusArea[] { return [{ id: "client-ops", label: "Client Ops", color: "emerald" }]; } +function focusAreaCounts(): Record { + return { "client-ops": 2 }; +} + function chatConversations(): AssistantChatConversation[] { return [ { diff --git a/src/client/components/Sidebar.tsx b/src/client/components/Sidebar.tsx index eaf06dd..09f2da8 100644 --- a/src/client/components/Sidebar.tsx +++ b/src/client/components/Sidebar.tsx @@ -11,6 +11,7 @@ import type { FocusArea, TaskStatus, } from "../../shared/types"; +import { Badge } from "@/components/ui/badge"; import { Sidebar as ShadcnSidebar, SidebarContent, @@ -38,11 +39,14 @@ export function Sidebar(props: { workspaceMode: WorkspaceMode; counts: Record; focusAreas: FocusArea[]; + focusAreaCounts: Record; + activeFocusAreaId: string | null; chatConversations: AssistantChatConversation[]; activeConversationId: string | null; chatHistoryLoading: boolean; onViewChange: (view: View) => void; onWorkspaceModeChange: (mode: WorkspaceMode) => void; + onFocusAreaChange: (focusAreaId: string | null) => void; onChatConversationSelect: (conversationId: string) => void; onNewChatConversation: () => void; }) { @@ -81,8 +85,18 @@ export function Sidebar(props: { } } + function selectFocusArea(focusAreaId: string | null) { + props.onFocusAreaChange(focusAreaId); + props.onWorkspaceModeChange("board"); + props.onViewChange("board"); + if (isMobile) { + setOpenMobile(false); + } + } + const activeWorkspaceMode = props.workspaceMode === "chat" ? "chat" : props.activeView === "board" ? "board" : ""; + const totalTaskCount = Object.values(props.counts).reduce((total, count) => total + count, 0); return ( @@ -153,7 +167,13 @@ export function Sidebar(props: { onNewConversation={createChatConversation} /> ) : ( - + )} @@ -190,24 +210,47 @@ export function Sidebar(props: { ); } -function FocusAreasGroup(props: { focusAreas: FocusArea[] }) { +function FocusAreasGroup(props: { + focusAreas: FocusArea[]; + focusAreaCounts: Record; + totalTaskCount: number; + activeFocusAreaId: string | null; + onFocusAreaChange: (focusAreaId: string | null) => void; +}) { return ( Focus areas + + props.onFocusAreaChange(null)} + > + + All work + + {props.totalTaskCount} + + + {props.focusAreas.map((area) => { const style = getFocusAreaStyle(area.color); return ( props.onFocusAreaChange(area.id)} > {area.label} + + {props.focusAreaCounts[area.id] ?? 0} + ); diff --git a/src/client/components/TaskCard.tsx b/src/client/components/TaskCard.tsx index 10d71d4..2c4e987 100644 --- a/src/client/components/TaskCard.tsx +++ b/src/client/components/TaskCard.tsx @@ -1,10 +1,27 @@ -import { AlertTriangle, Check, CircleStop, Clock3, LoaderCircle } from "lucide-react"; +import { + AlertTriangle, + Check, + CircleStop, + Clock3, + LoaderCircle, + MoreHorizontal, +} from "lucide-react"; import type { DragEvent, KeyboardEvent } from "react"; -import type { FocusArea, Priority, Task } from "../../shared/types"; -import { PRIORITY_LABELS } from "../../shared/types"; +import type { FocusArea, Priority, Task, TaskStatus } from "../../shared/types"; +import { COLUMN_LABELS, PRIORITY_LABELS, TASK_STATUSES } from "../../shared/types"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Item, + ItemActions, ItemContent, ItemDescription, ItemFooter, @@ -21,9 +38,11 @@ export function TaskCard(props: { task: Task; focusArea?: FocusArea; onEdit: () => void; + onMoveTask: (taskId: string, status: TaskStatus) => void; onDragStart: (event: DragEvent) => void; }) { const focusStyle = props.focusArea ? getFocusAreaStyle(props.focusArea.color) : null; + const isDone = props.task.status === "done"; function openFromKeyboard(event: KeyboardEvent) { if (event.key === "Enter" || event.key === " ") { @@ -34,75 +53,121 @@ export function TaskCard(props: { return ( - + ); } +function TaskActionsMenu(props: { + task: Task; + onMoveTask: (taskId: string, status: TaskStatus) => void; +}) { + return ( + + + + + + Move to + + {TASK_STATUSES.map((status) => ( + props.onMoveTask(props.task.id, status)} + > + {status === props.task.status && } + + {COLUMN_LABELS[status]} + + + ))} + + + + ); +} + function taskStatusIcon(task: Task) { if (task.execution?.status === "running") { return ; @@ -126,7 +191,7 @@ function priorityVariant(priority: Priority): "secondary" | "outline" | "destruc if (priority === "high") { return "destructive"; } - if (priority === "low") { + if (priority === "low" || priority === "medium") { return "outline"; } return "secondary";