From a797335ecdb2721f61763abd9599ac427943db11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 10 Jan 2026 22:22:34 +0000 Subject: [PATCH] feat: improved task page --- apps/web/app/task/[id]/page.tsx | 830 ++++++++++++++++++++----- apps/web/components/diff-stat.tsx | 10 +- apps/web/components/ui/collapsible.tsx | 33 + apps/web/components/ui/sheet.tsx | 134 ++++ apps/web/components/ui/sidebar.tsx | 702 +++++++++++++++++++++ apps/web/components/ui/skeleton.tsx | 13 + apps/web/components/ui/tooltip.tsx | 61 ++ apps/web/hooks/use-mobile.ts | 19 + apps/web/lib/local-storage.ts | 30 + apps/web/package-lock.json | 139 +++++ apps/web/package.json | 1 + 11 files changed, 1826 insertions(+), 146 deletions(-) create mode 100644 apps/web/components/ui/collapsible.tsx create mode 100644 apps/web/components/ui/sheet.tsx create mode 100644 apps/web/components/ui/sidebar.tsx create mode 100644 apps/web/components/ui/skeleton.tsx create mode 100644 apps/web/components/ui/tooltip.tsx create mode 100644 apps/web/hooks/use-mobile.ts create mode 100644 apps/web/lib/local-storage.ts diff --git a/apps/web/app/task/[id]/page.tsx b/apps/web/app/task/[id]/page.tsx index 8343ee20b5..1a3be7773f 100644 --- a/apps/web/app/task/[id]/page.tsx +++ b/apps/web/app/task/[id]/page.tsx @@ -1,9 +1,12 @@ 'use client'; -import { FormEvent, useState } from 'react'; +import { FormEvent, useMemo, useState } from 'react'; import Link from 'next/link'; +import { DiffModeEnum, DiffView } from '@git-diff-view/react'; +import '@git-diff-view/react/styles/diff-view.css'; import { IconArrowDown, + IconArrowBackUp, IconArrowLeft, IconBrandVscode, IconChevronDown, @@ -15,12 +18,44 @@ import { IconGitFork, IconGitMerge, IconGitPullRequest, + IconBrain, + IconLayoutColumns, + IconLayoutRows, IconPencil, + IconFile, + IconFolder, + IconListCheck, + IconPaperclip, + IconExternalLink, IconX, } from '@tabler/icons-react'; +import { useTheme } from 'next-themes'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarProvider, + SidebarRail, +} from '@/components/ui/sidebar'; import { Select, SelectContent, @@ -30,8 +65,15 @@ import { } from '@/components/ui/select'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { CommitStatBadge, LineStat } from '@/components/diff-stat'; +import { getLocalStorage, setLocalStorage } from '@/lib/local-storage'; type ChatMessage = { id: string; @@ -76,14 +118,105 @@ const CHANGED_FILES = [ { path: 'apps/web/components/kanban-column.tsx', status: 'D', plus: 0, minus: 18 }, ]; -const ALL_FILES = [ - 'apps/web/app/page.tsx', - 'apps/web/app/task/[id]/page.tsx', - 'apps/web/components/kanban-board.tsx', - 'apps/web/components/kanban-card.tsx', - 'apps/web/components/kanban-column.tsx', - 'apps/web/components/task-create-dialog.tsx', -]; +const DIFF_SAMPLES: Record = { + 'apps/web/components/kanban-board.tsx': { + diff: [ + 'diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx', + 'index 7b45ad2..9c0f2ee 100644', + '--- a/apps/web/components/kanban-board.tsx', + '+++ b/apps/web/components/kanban-board.tsx', + '@@ -14,6 +14,7 @@ export function KanbanBoard() {', + ' const columns = useMemo(() => getColumns(view), [view]);', + ' const tasks = useMemo(() => getTasks(), []);', + '+ const hasAlerts = tasks.some((task) => task.priority === "high");', + ' return (', + '
', + ' ', + ].join('\n'), + newContent: [ + 'export function KanbanBoard() {', + ' const columns = useMemo(() => getColumns(view), [view]);', + ' const tasks = useMemo(() => getTasks(), []);', + ' const hasAlerts = tasks.some((task) => task.priority === "high");', + ' return (', + '
', + ' ', + '
', + ' );', + '}', + ].join('\n'), + }, + 'apps/web/components/kanban-card.tsx': { + diff: [ + 'diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx', + 'index a14d022..a0cc12f 100644', + '--- a/apps/web/components/kanban-card.tsx', + '+++ b/apps/web/components/kanban-card.tsx', + '@@ -8,7 +8,8 @@ export function KanbanCard({ task }: KanbanCardProps) {', + ' return (', + ' ', + '-

{task.title}

', + '+

{task.title}

', + '+ {task.assignee}', + '

{task.summary}

', + '
', + ' );', + ].join('\n'), + newContent: [ + 'export function KanbanCard({ task }: KanbanCardProps) {', + ' return (', + ' ', + '

{task.title}

', + ' {task.assignee}', + '

{task.summary}

', + '
', + ' );', + '}', + ].join('\n'), + }, + 'apps/web/components/task-create-dialog.tsx': { + diff: [ + 'diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx', + 'new file mode 100644', + 'index 0000000..6c1a1f0', + '--- /dev/null', + '+++ b/apps/web/components/task-create-dialog.tsx', + '@@ -0,0 +1,6 @@', + '+export function TaskCreateDialog() {', + '+ return (', + '+ ', + '+ Create task', + '+ ', + '+ );', + '+}', + ].join('\n'), + newContent: [ + 'export function TaskCreateDialog() {', + ' return (', + ' ', + ' Create task', + ' ', + ' );', + '}', + ].join('\n'), + }, + 'apps/web/components/kanban-column.tsx': { + diff: [ + 'diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx', + 'deleted file mode 100644', + 'index 9a9b7aa..0000000', + '--- a/apps/web/components/kanban-column.tsx', + '+++ /dev/null', + '@@ -1,5 +0,0 @@', + '-export function KanbanColumn() {', + '- return (', + '-
Column
', + '- );', + '-}', + ].join('\n'), + newContent: '', + }, +}; const COMMANDS = [ { id: 'dev', label: 'npm run dev' }, @@ -91,6 +224,24 @@ const COMMANDS = [ { id: 'test', label: 'npm run test' }, ]; +const FILE_TREE = [ + [ + 'app', + ['api', ['hello', ['route.ts']], 'page.tsx', 'layout.tsx', ['blog', ['page.tsx']]], + ], + ['components', ['ui', 'button.tsx', 'card.tsx'], 'header.tsx', 'footer.tsx'], + ['lib', ['util.ts']], + ['public', 'favicon.ico', 'vercel.svg'], + '.eslintrc.json', + '.gitignore', + 'next.config.js', + 'tailwind.config.js', + 'package.json', + 'README.md', +]; + +type TreeItem = string | TreeItem[]; + const badgeClass = (status: string) => cn( 'text-[10px] font-semibold', @@ -108,10 +259,84 @@ const splitPath = (path: string) => { }; }; +function Tree({ item }: { item: TreeItem }) { + const [name, ...items] = Array.isArray(item) ? item : [item]; + + if (!items.length) { + return ( + + + {name} + + ); + } + + return ( + + + + + + + {name} + + + + + {items.map((subItem, index) => ( + + ))} + + + + + ); +} + +function buildDiffData(filePath: string) { + const sample = DIFF_SAMPLES[filePath]; + if (!sample) { + return { + hunks: [ + [ + `diff --git a/${filePath} b/${filePath}`, + 'index 0000000..0000000 100644', + `--- a/${filePath}`, + `+++ b/${filePath}`, + '@@ -1,1 +1,1 @@', + '-', + '+', + ].join('\n'), + ], + oldFile: { fileName: filePath, fileLang: 'ts' }, + newFile: { fileName: filePath, fileLang: 'ts' }, + }; + } + + return { + hunks: [sample.diff], + oldFile: { fileName: filePath, fileLang: 'ts' }, + newFile: { fileName: filePath, fileLang: 'ts' }, + }; +} + export default function TaskPage() { + const defaultHorizontalLayout = getLocalStorage<[number, number]>( + 'task-layout-horizontal', + [75, 25] + ); + const defaultRightLayout = getLocalStorage<[number, number]>('task-layout-right', [55, 45]); + const defaultDiffMode = getLocalStorage<'unified' | 'split'>( + 'task-diff-view-mode', + 'unified' + ); const [selectedAgent, setSelectedAgent] = useState(AGENTS[0].id); const [chats, setChats] = useState(INITIAL_CHATS); - const [leftTab, setLeftTab] = useState<'notes' | 'changes' | 'chat'>('chat'); + const [leftTab, setLeftTab] = useState('chat'); + const [selectedDiffPath, setSelectedDiffPath] = useState(null); const [messageInput, setMessageInput] = useState(''); const [topTab, setTopTab] = useState<'diff' | 'files'>('diff'); const [activeTerminalId, setActiveTerminalId] = useState(1); @@ -120,8 +345,22 @@ export default function TaskPage() { const [isBottomCollapsed, setIsBottomCollapsed] = useState(false); const [branchName, setBranchName] = useState('feature/agent-ui'); const [isEditingBranch, setIsEditingBranch] = useState(false); + const [diffViewMode, setDiffViewMode] = useState<'unified' | 'split'>(defaultDiffMode); + const [planModeEnabled, setPlanModeEnabled] = useState(false); + const { resolvedTheme } = useTheme(); const activeChat = chats[0]; + const selectedDiffLabel = selectedDiffPath ?? 'All files'; + const diffTargets = useMemo( + () => (selectedDiffPath ? [selectedDiffPath] : CHANGED_FILES.map((file) => file.path)), + [selectedDiffPath] + ); + const diffModeEnum = diffViewMode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified; + const diffTheme = resolvedTheme === 'dark' ? 'dark' : 'light'; + const isSingleDiffSelected = Boolean(selectedDiffPath && DIFF_SAMPLES[selectedDiffPath]); + const selectedDiffContent = selectedDiffPath + ? DIFF_SAMPLES[selectedDiffPath]?.newContent ?? '' + : ''; const handleSubmit = (event: FormEvent) => { event.preventDefault(); @@ -171,10 +410,14 @@ export default function TaskPage() { onValueChange={(value) => setTopTab(value as typeof topTab)} className="flex-1 min-h-0" > - - Diff files - All files - + + + Diff files + + + All files + +
    @@ -183,15 +426,49 @@ export default function TaskPage() { return (
  • { + setSelectedDiffPath(file.path); + setLeftTab('changes'); + }} > -
    +
    +
    +
    + + + + + Discard changes + + + + + + Open in editor + +
    {file.status}
    @@ -202,18 +479,23 @@ export default function TaskPage() {
-
-
    - {ALL_FILES.map((file) => { - const { folder, file: name } = splitPath(file); - return ( -
  • - {folder}/ - {name} -
  • - ); - })} -
+
+ + + + + + + {FILE_TREE.map((item, index) => ( + + ))} + + + + + + +
@@ -221,7 +503,8 @@ export default function TaskPage() { ); return ( -
+ +
- + + + + + Edit branch name + + + + + + Copy branch name +
)}
- - - +
+ + + + + + + Commits ahead of base + + + + + + + + Commits behind base + +
+ + + + + + + Lines changed +
- - - - + + + +
- - + setLocalStorage('task-layout-horizontal', sizes)} + > +
setLeftTab(value as typeof leftTab)} + onValueChange={(value) => setLeftTab(value)} className="flex-1 min-h-0" > - Notes - All changes - Chat {activeChat ? `• ${activeChat.title}` : ''} + + Notes + + + All changes + + + Chat {activeChat ? `• ${activeChat.title}` : ''} + @@ -360,15 +708,132 @@ export default function TaskPage() { -
-
{`diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx
-index 1234567..89abcde 100644
---- a/apps/web/components/kanban-board.tsx
-+++ b/apps/web/components/kanban-board.tsx
-@@ -1,5 +1,5 @@
--export function KanbanBoard() {
-+export function KanbanBoard() {
- // ...`}
+
+
+ + {selectedDiffLabel} + +
+ {selectedDiffPath && ( + + + + + Show all changes + + )} + + + + + + + Copy file contents + + + + + + + + Discard changes + +
+ + + + + + + Unified view + + + + + + + + Split view + +
+
+
+
+
+ {diffTargets.map((path) => ( +
+ {!selectedDiffPath && ( +
+ + {path} + +
+ )} + +
+ ))} +
+
@@ -396,22 +861,91 @@ index 1234567..89abcde 100644 value={messageInput} onChange={(event) => setMessageInput(event.target.value)} placeholder="Write to submit work to the agent..." - className="min-h-[90px] resize-none" + className={cn( + 'min-h-[90px] resize-none', + planModeEnabled && + 'border-dashed border-primary/60 !bg-primary/20 dark:!bg-primary/20 shadow-[inset_0_0_0_1px_rgba(59,130,246,0.35)]' + )} />
- - +
+ + + + + + + + + Thinking level + + + High + Medium + Low + Off + + + + +
+ + {planModeEnabled && ( + + Plan mode active + + )} +
+
+ Toggle plan mode +
+
+
+ + + + + Add attachments + + +
@@ -419,7 +953,7 @@ index 1234567..89abcde 100644
- + {isBottomCollapsed ? (
{topFilesPanel}
@@ -439,13 +973,15 @@ index 1234567..89abcde 100644 className="flex-1 min-h-0" > - Commands + + Commands + {terminalIds.map((id) => ( - + {id === 1 ? 'Terminal' : `Terminal ${id}`} ))} - + + @@ -460,12 +996,16 @@ index 1234567..89abcde 100644
) : ( - - + setLocalStorage('task-layout-right', sizes)} + > + {topFilesPanel} - +
- Commands + + Commands + {terminalIds.map((id) => ( -
- - {id === 1 ? 'Terminal' : `Terminal ${id}`} - - -
+ + {id === 1 ? 'Terminal' : `Terminal ${id}`} + {terminalIds.length > 1 && ( + { + event.preventDefault(); + event.stopPropagation(); + removeTerminal(id); + }} + > + + + )} + ))} - + +
@@ -544,6 +1095,7 @@ index 1234567..89abcde 100644
-
+
+ ); } diff --git a/apps/web/components/diff-stat.tsx b/apps/web/components/diff-stat.tsx index 89d1b89a82..7f3f35502b 100644 --- a/apps/web/components/diff-stat.tsx +++ b/apps/web/components/diff-stat.tsx @@ -16,8 +16,8 @@ type LineStatProps = { }; const commitTone = { - ahead: 'text-emerald-600', - behind: 'text-yellow-600', + ahead: 'text-emerald-200 bg-emerald-600/40', + behind: 'text-yellow-200 bg-yellow-600/40', }; const lineTone = { @@ -29,11 +29,7 @@ export function CommitStatBadge({ label, tone, className }: CommitStatBadgeProps return ( {label} diff --git a/apps/web/components/ui/collapsible.tsx b/apps/web/components/ui/collapsible.tsx new file mode 100644 index 0000000000..2f7a4e7fc6 --- /dev/null +++ b/apps/web/components/ui/collapsible.tsx @@ -0,0 +1,33 @@ +"use client" + +import { Collapsible as CollapsiblePrimitive } from "radix-ui" + +function Collapsible({ + ...props +}: React.ComponentProps) { + return +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/apps/web/components/ui/sheet.tsx b/apps/web/components/ui/sheet.tsx new file mode 100644 index 0000000000..db237bc414 --- /dev/null +++ b/apps/web/components/ui/sheet.tsx @@ -0,0 +1,134 @@ +"use client" + +import * as React from "react" +import { Dialog as SheetPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { IconX } from "@tabler/icons-react" + +function Sheet({ ...props }: React.ComponentProps) { + return +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/apps/web/components/ui/sidebar.tsx b/apps/web/components/ui/sidebar.tsx new file mode 100644 index 0000000000..691b060052 --- /dev/null +++ b/apps/web/components/ui/sidebar.tsx @@ -0,0 +1,702 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { useIsMobile } from "@/hooks/use-mobile" +import { IconLayoutSidebar } from "@tabler/icons-react" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + +
+ {children} +
+
+ ) +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offExamples", + className, + children, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offExamples" | "icon" | "none" +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( +