feat: full screen task attempt view (#553)

* full screen task attempt view

* fix tsc errors

* remove padding

* Full screen view improvements (#568)

* delete FullscreenHeaderContext.tsx

* add fullscreen responsive layout

* full screen props

* cleanup fullscreen

* fmt

* task status

* task actions

* simplify toolbar

* reset CurrentAttempt

* buttons

* Truncate properly branch name

* fmt

* polish

* fmt

---------

Co-authored-by: Louis Knight-Webb <louis@bloop.ai>
This commit is contained in:
Gabriel Gordon-Hall
2025-08-23 17:39:42 +01:00
committed by GitHub
parent 3d6013ba32
commit 37e401fb0f
18 changed files with 678 additions and 551 deletions

View File

@@ -50,6 +50,7 @@
"zustand": "^4.5.4"
},
"devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.21.0",
@@ -2921,6 +2922,16 @@
"node": ">= 14"
}
},
"node_modules/@tailwindcss/container-queries": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz",
"integrity": "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.2.0"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",

View File

@@ -51,11 +51,12 @@
"react-virtuoso": "^4.13.0",
"react-window": "^1.8.11",
"rfc6902": "^5.1.2",
"zustand": "^4.5.4",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"zustand": "^4.5.4"
},
"devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.21.0",
@@ -74,4 +75,4 @@
"typescript": "^5.9.2",
"vite": "^5.0.8"
}
}
}

View File

@@ -1,84 +0,0 @@
import React, { useState } from 'react';
import {
ChevronDown,
ChevronUp,
Circle,
CircleCheck,
CircleDotDashed,
} from 'lucide-react';
import type { TodoItem } from 'shared/types';
interface PinnedTodoBoxProps {
todos: TodoItem[];
lastUpdated: string | null;
}
const getStatusIcon = (status: string): React.ReactNode => {
switch (status.toLowerCase()) {
case 'completed':
return <CircleCheck className="h-4 w-4 text-green-500" />;
case 'in_progress':
case 'in-progress':
return <CircleDotDashed className="h-4 w-4 text-blue-500" />;
case 'pending':
case 'todo':
return <Circle className="h-4 w-4 text-gray-400" />;
default:
return <Circle className="h-4 w-4 text-gray-400" />;
}
};
export const PinnedTodoBox: React.FC<PinnedTodoBoxProps> = ({ todos }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
if (todos.length === 0) return null;
return (
<div className="sticky top-0 z-10 border-b bg-muted/20">
{isCollapsed && (
<div
className="flex items-center justify-between px-4 py-2 cursor-pointer"
onClick={() => setIsCollapsed(!isCollapsed)}
>
<div className="flex items-center gap-2">
<CircleCheck className="h-4 w-4 text-primary" />
<span className="text-sm text-primary">TODOs</span>
</div>
<div className="flex items-center gap-2">
<ChevronDown className="h-4 w-4 text-primary" />
</div>
</div>
)}
{!isCollapsed && (
<div className="relative">
<div className="absolute top-2 right-2 z-20">
<button
className="flex items-center justify-center p-1 cursor-pointer hover:bg-muted/40 rounded"
onClick={() => setIsCollapsed(!isCollapsed)}
>
<ChevronUp className="h-4 w-4 text-primary" />
</button>
</div>
<div className="px-4 py-2 pr-10 space-y-2 max-h-64 overflow-y-auto">
{todos.map((todo, index) => (
<div
key={`${todo.content}-${index}`}
className="flex items-start gap-2 text-sm"
>
<span className="mt-0.5 flex-shrink-0">
{getStatusIcon(todo.status)}
</span>
<div className="flex-1 min-w-0">
<span className="break-words text-primary">
{todo.content}
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};

View File

@@ -112,7 +112,7 @@ function BranchSelector({
size="sm"
className={`w-full justify-between text-xs ${className}`}
>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 w-full">
<GitBranchIcon className="h-3 w-3" />
<span className="truncate">{displayName}</span>
</div>

View File

@@ -13,9 +13,7 @@ import {
TaskSelectedAttemptContext,
} from '@/components/context/taskDetailsContext.ts';
import { useProcessesLogs } from '@/hooks/useProcessesLogs';
import { usePinnedTodos } from '@/hooks/usePinnedTodos';
import LogEntryRow from '@/components/logs/LogEntryRow';
import { PinnedTodoBox } from '@/components/PinnedTodoBox';
import {
shouldShowInLogs,
isAutoCollapsibleProcess,
@@ -139,9 +137,6 @@ function LogsTab() {
const { entries } = useProcessesLogs(filteredProcesses, true);
// Extract todos from entries using the usePinnedTodos hook
const { todos, lastUpdated } = usePinnedTodos(entries);
// Combined collapsed processes (auto + user)
const allCollapsedProcesses = useMemo(() => {
const combined = new Set(state.autoCollapsed);
@@ -282,7 +277,6 @@ function LogsTab() {
return (
<div className="w-full h-full flex flex-col">
<PinnedTodoBox todos={todos} lastUpdated={lastUpdated} />
<div className="flex-1">
<Virtuoso
ref={virtuosoRef}

View File

@@ -6,13 +6,14 @@ import type { TabType } from '@/types/tabs';
type Props = {
activeTab: TabType;
setActiveTab: (tab: TabType) => void;
rightContent?: React.ReactNode;
};
function TabNavigation({ activeTab, setActiveTab }: Props) {
function TabNavigation({ activeTab, setActiveTab, rightContent }: Props) {
const { attemptData } = useContext(TaskAttemptDataContext);
return (
<div className="border-b bg-muted/20">
<div className="flex px-4">
<div className="border-b bg-muted/20 sticky top-0 z-10">
<div className="flex items-center px-4">
<button
onClick={() => {
setActiveTab('logs');
@@ -58,6 +59,7 @@ function TabNavigation({ activeTab, setActiveTab }: Props) {
</span>
)}
</button>
<div className="ml-auto flex items-center">{rightContent}</div>
</div>
</div>
);

View File

@@ -1,5 +1,13 @@
import { memo, useContext, useState } from 'react';
import { ChevronDown, ChevronUp, Edit, Trash2, X } from 'lucide-react';
import {
ChevronDown,
ChevronUp,
Edit,
Trash2,
X,
Maximize2,
Minimize2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Chip } from '@/components/ui/chip';
import {
@@ -16,6 +24,8 @@ interface TaskDetailsHeaderProps {
onEditTask?: (task: TaskWithAttemptStatus) => void;
onDeleteTask?: (taskId: string) => void;
hideCloseButton?: boolean;
isFullScreen?: boolean;
setFullScreen?: (isFullScreen: boolean) => void;
}
const statusLabels: Record<TaskStatus, string> = {
@@ -48,6 +58,8 @@ function TaskDetailsHeader({
onEditTask,
onDeleteTask,
hideCloseButton = false,
isFullScreen,
setFullScreen,
}: TaskDetailsHeaderProps) {
const { task } = useContext(TaskDetailsContext);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
@@ -55,116 +67,153 @@ function TaskDetailsHeader({
return (
<div>
{/* Title and Task Actions */}
<div className="p-4 pb-2">
<div className="p-4 pb-2 border-b-2 border-muted">
{/* Top row: title and action icons */}
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold mb-1 line-clamp-2">
{task.title}
</h2>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Chip dotColor={getTaskStatusDotColor(task.status)}>
{statusLabels[task.status]}
</Chip>
<div className="flex-1 min-w-0 flex items-start gap-2">
<div className="min-w-0 flex-1">
<h2 className="text-lg font-bold mb-1 line-clamp-2">
{task.title}
<Chip
className="ml-2 -mt-2 relative top-[-2px]"
dotColor={getTaskStatusDotColor(task.status)}
>
{statusLabels[task.status]}
</Chip>
</h2>
</div>
</div>
<div className="flex items-center gap-1">
{onEditTask && (
{setFullScreen && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onEditTask(task)}
onClick={() => setFullScreen(!isFullScreen)}
aria-label={
isFullScreen
? 'Collapse to sidebar'
: 'Expand to fullscreen'
}
>
<Edit className="h-4 w-4" />
{isFullScreen ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit task</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{onDeleteTask && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onDeleteTask(task.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete task</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{!hideCloseButton && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Close panel</p>
<p>
{isFullScreen
? 'Collapse to sidebar'
: 'Expand to fullscreen'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<div className="flex items-center gap-1">
{onEditTask && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onEditTask(task)}
>
<Edit className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit task</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{onDeleteTask && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onDeleteTask(task.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete task</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{!hideCloseButton && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Close panel</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
</div>
{/* Description */}
<div className="mt-2">
<div className="p-2 bg-muted/20 rounded border-l-2 border-muted max-h-48 overflow-y-auto">
{task.description ? (
<div>
<p
className={`text-xs whitespace-pre-wrap text-muted-foreground ${
!isDescriptionExpanded && task.description.length > 150
? 'line-clamp-3'
: ''
}`}
>
{task.description}
</p>
{task.description.length > 150 && (
<Button
variant="ghost"
size="sm"
onClick={() =>
setIsDescriptionExpanded(!isDescriptionExpanded)
}
className="mt-1 p-0 h-auto text-xs text-muted-foreground hover:text-foreground"
>
{isDescriptionExpanded ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Show less
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Show more
</>
{/* Description + Status (sidebar view only) lives below icons */}
{!isFullScreen && (
<div className="mt-2">
<div className="p-2 bg-muted/20 rounded border-l-2 border-muted max-h-48 overflow-y-auto">
<div className="flex items-start gap-2 text-xs text-muted-foreground">
{task.description ? (
<div className="flex-1 min-w-0">
<p
className={`whitespace-pre-wrap ${
!isDescriptionExpanded && task.description.length > 150
? 'line-clamp-3'
: ''
}`}
>
{task.description}
</p>
{task.description.length > 150 && (
<Button
variant="ghost"
size="sm"
onClick={() =>
setIsDescriptionExpanded(!isDescriptionExpanded)
}
className="mt-1 p-0 h-auto text-xs text-muted-foreground hover:text-foreground"
>
{isDescriptionExpanded ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Show less
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Show more
</>
)}
</Button>
)}
</Button>
</div>
) : (
<p className="italic">No description provided</p>
)}
</div>
) : (
<p className="text-xs text-muted-foreground italic">
No description provided
</p>
)}
</div>
</div>
</div>
)}
</div>
</div>
);

View File

@@ -12,11 +12,15 @@ import DiffTab from '@/components/tasks/TaskDetails/DiffTab.tsx';
import LogsTab from '@/components/tasks/TaskDetails/LogsTab.tsx';
import ProcessesTab from '@/components/tasks/TaskDetails/ProcessesTab.tsx';
import DeleteFileConfirmationDialog from '@/components/tasks/DeleteFileConfirmationDialog.tsx';
import CreatePRDialog from '@/components/tasks/Toolbar/CreatePRDialog';
import TabNavigation from '@/components/tasks/TaskDetails/TabNavigation.tsx';
import TaskDetailsProvider from '../context/TaskDetailsContextProvider.tsx';
import TaskDetailsToolbar from './TaskDetailsToolbar.tsx';
import TodoPanel from '@/components/tasks/TodoPanel';
import { TabNavContext } from '@/contexts/TabNavigationContext';
import { ProcessSelectionProvider } from '@/contexts/ProcessSelectionContext';
import { projectsApi } from '@/lib/api';
import type { GitBranch } from 'shared/types';
interface TaskDetailsPanelProps {
task: TaskWithAttemptStatus | null;
@@ -29,6 +33,11 @@ interface TaskDetailsPanelProps {
hideBackdrop?: boolean;
className?: string;
hideHeader?: boolean;
isFullScreen?: boolean;
setFullScreen?: (value: boolean) => void;
forceCreateAttempt?: boolean;
onLeaveForceCreateAttempt?: () => void;
onNewAttempt?: () => void;
}
export function TaskDetailsPanel({
@@ -41,9 +50,16 @@ export function TaskDetailsPanel({
isDialogOpen = false,
hideBackdrop = false,
className,
hideHeader = false,
isFullScreen,
setFullScreen,
forceCreateAttempt,
onLeaveForceCreateAttempt,
}: TaskDetailsPanelProps) {
const [showEditorDialog, setShowEditorDialog] = useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [creatingPR, setCreatingPR] = useState(false);
const [, setPrError] = useState<string | null>(null);
const [branches, setBranches] = useState<GitBranch[]>([]);
// Tab and collapsible state
const [activeTab, setActiveTab] = useState<TabType>('logs');
@@ -55,6 +71,19 @@ export function TaskDetailsPanel({
}
}, [task?.id]);
// Fetch branches for PR dialog usage when panel opens
useEffect(() => {
const fetchBranches = async () => {
try {
const result = await projectsApi.getBranches(projectId);
setBranches(result);
} catch (e) {
// noop
}
};
if (projectId) fetchBranches();
}, [projectId]);
// Handle ESC key locally to prevent global navigation
useEffect(() => {
if (isDialogOpen) return;
@@ -85,40 +114,102 @@ export function TaskDetailsPanel({
<ProcessSelectionProvider>
{/* Backdrop - only on smaller screens (overlay mode) */}
{!hideBackdrop && (
<div className={getBackdropClasses()} onClick={onClose} />
<div
className={getBackdropClasses(isFullScreen || false)}
onClick={onClose}
/>
)}
{/* Panel */}
<div className={className || getTaskPanelClasses()}>
<div
className={
className || getTaskPanelClasses(isFullScreen || false)
}
>
<div className="flex flex-col h-full">
{!hideHeader && (
<TaskDetailsHeader
onClose={onClose}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
hideCloseButton={hideBackdrop}
/>
)}
<TaskDetailsToolbar />
<TabNavigation
activeTab={activeTab}
setActiveTab={setActiveTab}
<TaskDetailsHeader
onClose={onClose}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
hideCloseButton={hideBackdrop}
isFullScreen={isFullScreen}
setFullScreen={setFullScreen}
/>
{/* Tab Content */}
<div className="flex-1 flex flex-col min-h-0">
{activeTab === 'diffs' ? (
<DiffTab />
) : activeTab === 'processes' ? (
<ProcessesTab />
) : (
<LogsTab />
)}
</div>
{isFullScreen ? (
<div className="flex-1 min-h-0 flex">
{/* Sidebar */}
<aside className="w-[28rem] shrink-0 border-r overflow-y-auto p-4 space-y-4">
{/* Fullscreen sidebar shows description only (no title) above edit/delete */}
<div className="space-y-2">
{/* Description */}
<div className="text-sm text-muted-foreground block">
{task.description ? (
<p className="whitespace-pre-wrap break-words">
{task.description}
</p>
) : (
<p className="italic">No description provided</p>
)}
</div>
</div>
<TaskFollowUpSection />
{/* Current Attempt / Actions */}
<TaskDetailsToolbar
forceCreateAttempt={forceCreateAttempt}
onLeaveForceCreateAttempt={onLeaveForceCreateAttempt}
// hide actions in sidebar; moved to header in fullscreen
/>
{/* Task Breakdown (TODOs) */}
<TodoPanel />
</aside>
{/* Main content */}
<main className="flex-1 min-h-0 flex flex-col">
<TabNavigation
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
<div className="flex-1 flex flex-col min-h-0">
{activeTab === 'diffs' ? (
<DiffTab />
) : activeTab === 'processes' ? (
<ProcessesTab />
) : (
<LogsTab />
)}
</div>
<TaskFollowUpSection />
</main>
</div>
) : (
<>
<div className="p-4 border-b">
<TaskDetailsToolbar />
</div>
<TabNavigation
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
{/* Tab Content */}
<div className="flex-1 flex flex-col min-h-0">
{activeTab === 'diffs' ? (
<DiffTab />
) : activeTab === 'processes' ? (
<ProcessesTab />
) : (
<LogsTab />
)}
</div>
<TaskFollowUpSection />
</>
)}
</div>
</div>
@@ -130,6 +221,15 @@ export function TaskDetailsPanel({
<DeleteFileConfirmationDialog />
</ProcessSelectionProvider>
</TabNavContext.Provider>
{/* PR Dialog mounted within provider so it has task context */}
<CreatePRDialog
creatingPR={creatingPR}
setShowCreatePRDialog={setShowCreatePRDialog}
showCreatePRDialog={showCreatePRDialog}
setCreatingPR={setCreatingPR}
setError={setPrError}
branches={branches}
/>
</TaskDetailsProvider>
)}
</>

View File

@@ -70,7 +70,13 @@ function uiReducer(state: UiState, action: UiAction): UiState {
}
}
function TaskDetailsToolbar() {
function TaskDetailsToolbar({
forceCreateAttempt,
onLeaveForceCreateAttempt,
}: {
forceCreateAttempt?: boolean;
onLeaveForceCreateAttempt?: () => void;
}) {
const { task, projectId } = useContext(TaskDetailsContext);
const { setLoading } = useContext(TaskAttemptLoadingContext);
const { selectedAttempt, setSelectedAttempt } = useContext(
@@ -109,7 +115,8 @@ function TaskDetailsToolbar() {
// Derived state
const isInCreateAttemptMode =
ui.userForcedCreateMode || taskAttempts.length === 0;
forceCreateAttempt ??
(ui.userForcedCreateMode || taskAttempts.length === 0);
// Derive createAttemptBranch for backward compatibility
const createAttemptBranch = useMemo(() => {
@@ -291,10 +298,11 @@ function TaskDetailsToolbar() {
if (boolValue) {
dispatch({ type: 'ENTER_CREATE_MODE' });
} else {
if (onLeaveForceCreateAttempt) onLeaveForceCreateAttempt();
dispatch({ type: 'LEAVE_CREATE_MODE' });
}
},
[isInCreateAttemptMode]
[isInCreateAttemptMode, onLeaveForceCreateAttempt]
);
// Wrapper functions for UI state dispatch
@@ -326,7 +334,7 @@ function TaskDetailsToolbar() {
return (
<>
<div className="p-4 border-b">
<div>
{/* Error Display */}
{ui.error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
@@ -375,13 +383,13 @@ function TaskDetailsToolbar() {
)}
</div>
{/* Special Actions */}
{/* Special Actions: show only in sidebar (non-fullscreen) */}
{!selectedAttempt && !isAttemptRunning && !isStopping && (
<div className="space-y-2 pt-3 border-t">
<Button
onClick={handleEnterCreateAttemptMode}
size="sm"
className="w-full gap-2"
className="w-full gap-2 bg-black text-white hover:bg-black/90"
>
<Play className="h-4 w-4" />
Start Attempt

View File

@@ -0,0 +1,58 @@
import { useContext, useMemo } from 'react';
import { Circle, CircleCheckBig, CircleDotDashed } from 'lucide-react';
import { useProcessesLogs } from '@/hooks/useProcessesLogs';
import { usePinnedTodos } from '@/hooks/usePinnedTodos';
import { TaskAttemptDataContext } from '@/components/context/taskDetailsContext';
import { shouldShowInLogs } from '@/constants/processes';
function getStatusIcon(status?: string) {
const s = (status || '').toLowerCase();
if (s === 'completed')
return <CircleCheckBig aria-hidden className="h-4 w-4 text-green-600" />;
if (s === 'in_progress' || s === 'in-progress')
return <CircleDotDashed aria-hidden className="h-4 w-4 text-blue-500" />;
return <Circle aria-hidden className="h-4 w-4 text-muted-foreground" />;
}
export function TodoPanel() {
const { attemptData } = useContext(TaskAttemptDataContext);
const filteredProcesses = useMemo(
() =>
(attemptData.processes || []).filter((p) =>
shouldShowInLogs(p.run_reason)
),
[attemptData.processes?.map((p) => p.id).join(',')]
);
const { entries } = useProcessesLogs(filteredProcesses, true);
const { todos } = usePinnedTodos(entries);
// Only show once the agent has created subtasks
if (!todos || todos.length === 0) return null;
return (
<div className="bg-background rounded-lg overflow-hidden border">
<div className="p-4">
<h3 className="font-medium mb-3">Task Breakdown</h3>
<ul className="space-y-2">
{todos.map((todo, index) => (
<li
key={`${todo.content}-${index}`}
className="flex items-start gap-2"
>
<span className="mt-0.5 h-4 w-4 flex items-center justify-center shrink-0">
{getStatusIcon(todo.status)}
</span>
<span className="text-sm leading-5 break-words">
{todo.content}
</span>
</li>
))}
</ul>
</div>
</div>
);
}
export default TodoPanel;

View File

@@ -159,7 +159,7 @@ function CreateAttempt({
</Button>
)}
</div>
<div className="flex items-center w-4/5">
<div className="flex items-center">
<label className="text-xs font-medium text-muted-foreground">
Each time you start an attempt, a new session is initiated with your
selected coding agent, and a git worktree and corresponding task
@@ -167,7 +167,7 @@ function CreateAttempt({
</label>
</div>
<div className="grid grid-cols-3 gap-3 items-end">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-end">
{/* Step 1: Choose Base Branch */}
<div className="space-y-1">
<div className="flex items-center gap-1.5">
@@ -183,136 +183,147 @@ function CreateAttempt({
/>
</div>
{/* Step 2: Choose Profile and Mode */}
{/* Step 2: Choose Profile */}
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Profile
</label>
</div>
<div className="flex gap-2">
{availableProfiles && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex-1 justify-between text-xs"
{availableProfiles && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full justify-between text-xs"
>
<div className="flex items-center gap-1.5">
<Settings2 className="h-3 w-3" />
<span className="truncate">
{selectedProfile?.profile || 'Select profile'}
</span>
</div>
<ArrowDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full">
{availableProfiles.map((profile) => (
<DropdownMenuItem
key={profile.label}
onClick={() => {
setSelectedProfile({
profile: profile.label,
variant: null,
});
}}
className={
selectedProfile?.profile === profile.label
? 'bg-accent'
: ''
}
>
<div className="flex items-center gap-1.5">
<Settings2 className="h-3 w-3" />
<span className="truncate">
{selectedProfile?.profile || 'Select profile'}
</span>
</div>
<ArrowDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full">
{availableProfiles.map((profile) => (
<DropdownMenuItem
key={profile.label}
onClick={() => {
setSelectedProfile({
profile: profile.label,
variant: null,
});
}}
className={
selectedProfile?.profile === profile.label
? 'bg-accent'
: ''
}
{profile.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Step 3: Choose Variant (if available) */}
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Variant
</label>
</div>
{(() => {
const currentProfile = availableProfiles?.find(
(p) => p.label === selectedProfile?.profile
);
const hasVariants =
currentProfile?.variants && currentProfile.variants.length > 0;
if (hasVariants && currentProfile) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full px-2 flex items-center justify-between text-xs"
>
{profile.label}
<span className="truncate flex-1 text-left">
{selectedProfile?.variant || 'Default'}
</span>
<ArrowDown className="h-3 w-3 ml-1 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full">
<DropdownMenuItem
onClick={() => {
if (selectedProfile) {
setSelectedProfile({
...selectedProfile,
variant: null,
});
}
}}
className={!selectedProfile?.variant ? 'bg-accent' : ''}
>
Default
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Show variant dropdown or disabled button */}
{(() => {
const currentProfile = availableProfiles?.find(
(p) => p.label === selectedProfile?.profile
);
const hasVariants =
currentProfile?.variants &&
currentProfile.variants.length > 0;
if (hasVariants) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-24 px-2 flex items-center justify-between text-xs"
>
<span className="truncate flex-1 text-left">
{selectedProfile?.variant || 'Default'}
</span>
<ArrowDown className="h-3 w-3 ml-1 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{currentProfile.variants.map((variant) => (
<DropdownMenuItem
key={variant.label}
onClick={() => {
if (selectedProfile) {
setSelectedProfile({
...selectedProfile,
variant: null,
variant: variant.label,
});
}
}}
className={
!selectedProfile?.variant ? 'bg-accent' : ''
selectedProfile?.variant === variant.label
? 'bg-accent'
: ''
}
>
Default
{variant.label}
</DropdownMenuItem>
{currentProfile.variants.map((variant) => (
<DropdownMenuItem
key={variant.label}
onClick={() => {
if (selectedProfile) {
setSelectedProfile({
...selectedProfile,
variant: variant.label,
});
}
}}
className={
selectedProfile?.variant === variant.label
? 'bg-accent'
: ''
}
>
{variant.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
} else if (currentProfile) {
// Show disabled button when profile exists but has no variants
return (
<Button
variant="outline"
size="sm"
className="w-24 px-2 flex items-center justify-between text-xs"
disabled
>
<span className="truncate flex-1 text-left">Default</span>
</Button>
);
}
return null;
})()}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
if (currentProfile) {
return (
<Button
variant="outline"
size="sm"
disabled
className="w-full text-xs justify-start"
>
Default
</Button>
);
}
return (
<Button
variant="outline"
size="sm"
disabled
className="w-full text-xs justify-start"
>
Select profile first
</Button>
);
})()}
</div>
{/* Step 3: Start Attempt */}
{/* Step 4: Start Attempt */}
<div className="space-y-1">
<Button
onClick={handleCreateAttempt}
@@ -320,7 +331,9 @@ function CreateAttempt({
!selectedProfile || !createAttemptBranch || isAttemptRunning
}
size="sm"
className={'w-full text-xs gap-2'}
className={
'w-full text-xs gap-2 justify-center bg-black text-white hover:bg-black/90'
}
title={
!createAttemptBranch
? 'Base branch is required'
@@ -355,7 +368,12 @@ function CreateAttempt({
>
Cancel
</Button>
<Button onClick={handleConfirmCreateAttempt}>Start</Button>
<Button
onClick={handleConfirmCreateAttempt}
className="bg-black text-white hover:bg-black/90"
>
Start
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -124,6 +124,7 @@ function CreatePrDialog({
prBaseBranch,
prBody,
prTitle,
fetchAttemptData,
setCreatingPR,
setError,
setShowCreatePRDialog,

View File

@@ -38,11 +38,9 @@ import {
SetStateAction,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import type { ExecutionProcess } from 'shared/types';
import type { GitBranch, TaskAttempt } from 'shared/types';
import {
TaskAttemptDataContext,
@@ -111,9 +109,6 @@ function CurrentAttempt({
const [merging, setMerging] = useState(false);
const [pushing, setPushing] = useState(false);
const [rebasing, setRebasing] = useState(false);
const [devServerDetails, setDevServerDetails] =
useState<ExecutionProcess | null>(null);
const [isHoveringDevServer, setIsHoveringDevServer] = useState(false);
const [showRebaseDialog, setShowRebaseDialog] = useState(false);
const [selectedRebaseBranch, setSelectedRebaseBranch] = useState<string>('');
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
@@ -121,14 +116,6 @@ function CurrentAttempt({
const [mergeSuccess, setMergeSuccess] = useState(false);
const [pushSuccess, setPushSuccess] = useState(false);
const processedDevServerLogs = useMemo(() => {
if (!devServerDetails) return 'No output yet...';
// TODO: stdout/stderr fields need to be restored to ExecutionProcess type
// For now, show basic status information
return `Status: ${devServerDetails.status}\nStarted: ${devServerDetails.started_at}`;
}, [devServerDetails]);
// Find running dev server in current project
const runningDevServer = useMemo(() => {
return attemptData.processes.find(
@@ -147,30 +134,6 @@ function CurrentAttempt({
)[0];
}, [attemptData.processes]);
const fetchDevServerDetails = useCallback(async () => {
if (!runningDevServer || !task || !selectedAttempt) return;
try {
const result = await executionProcessesApi.getDetails(
runningDevServer.id
);
setDevServerDetails(result);
} catch (err) {
console.error('Failed to fetch dev server details:', err);
}
}, [runningDevServer, task, selectedAttempt, projectId]);
useEffect(() => {
if (!isHoveringDevServer || !runningDevServer) {
setDevServerDetails(null);
return;
}
fetchDevServerDetails();
const interval = setInterval(fetchDevServerDetails, 2000);
return () => clearInterval(interval);
}, [isHoveringDevServer, runningDevServer, fetchDevServerDetails]);
const startDevServer = async () => {
if (!task || !selectedAttempt) return;
@@ -479,8 +442,9 @@ function CurrentAttempt({
}, [mergeInfo, branchStatus]);
return (
<div className="space-y-2">
<div className="flex gap-6 items-start">
<div className="space-y-2 @container">
{/* <div className="flex gap-6 items-start"> */}
<div className="grid grid-cols-2 gap-3 items-start @md:flex @md:items-start">
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
Profile
@@ -538,7 +502,7 @@ function CurrentAttempt({
{(() => {
const statusInfo = getStatusInfo();
return (
<div className="flex items-center gap-1.5">
<>
<div
className={`h-2 w-2 ${statusInfo.dotColor} rounded-full`}
/>
@@ -551,19 +515,19 @@ function CurrentAttempt({
</button>
) : (
<span
className={`text-sm font-medium ${statusInfo.textColor}`}
className={`text-sm font-medium ${statusInfo.textColor} truncate`}
>
{statusInfo.text}
</span>
)}
</div>
</>
);
})()}
</div>
</div>
</div>
<div className="col-span-4">
<div>
<div className="flex items-center gap-1.5 mb-1">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1 pt-1">
Path
@@ -601,89 +565,50 @@ function CurrentAttempt({
</div>
</div>
<div className="col-span-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={!projectHasDevScript ? 'cursor-not-allowed' : ''}
onMouseEnter={() => setIsHoveringDevServer(true)}
onMouseLeave={() => setIsHoveringDevServer(false)}
>
<Button
variant={runningDevServer ? 'destructive' : 'outline'}
size="xs"
onClick={runningDevServer ? stopDevServer : startDevServer}
disabled={isStartingDevServer || !projectHasDevScript}
className="gap-1"
>
{runningDevServer ? (
<>
<StopCircle className="h-3 w-3" />
Stop Dev
</>
) : (
<>
<Play className="h-3 w-3" />
Dev
</>
)}
</Button>
</div>
</TooltipTrigger>
<TooltipContent
className={runningDevServer ? 'max-w-2xl p-4' : ''}
side="top"
align="center"
avoidCollisions={true}
>
{!projectHasDevScript ? (
<p>
Add a dev server script in project settings to enable this
feature
</p>
) : runningDevServer && devServerDetails ? (
<div className="space-y-2">
<p className="text-sm font-medium">
Dev Server Logs (Last 10 lines):
</p>
<pre className="text-xs bg-muted p-2 rounded max-h-64 overflow-y-auto whitespace-pre-wrap">
{processedDevServerLogs}
</pre>
</div>
) : runningDevServer ? (
<p>Stop the running dev server</p>
) : (
<p>Start the dev server</p>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div>
<div className="grid grid-cols-2 gap-3 @md:flex @md:flex-wrap @md:items-center">
<div className="flex gap-2 @md:flex-none">
<Button
variant={runningDevServer ? 'destructive' : 'outline'}
size="xs"
onClick={runningDevServer ? stopDevServer : startDevServer}
disabled={isStartingDevServer || !projectHasDevScript}
className="gap-1 flex-1"
>
{runningDevServer ? (
<>
<StopCircle className="h-3 w-3" />
Stop Dev
</>
) : (
<>
<Play className="h-3 w-3" />
Dev
</>
)}
</Button>
{/* View Dev Server Logs Button */}
{latestDevServerProcess && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="xs"
onClick={handleViewDevServerLogs}
className="gap-1"
>
<ScrollText className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View dev server logs</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* View Dev Server Logs Button */}
{latestDevServerProcess && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="xs"
onClick={handleViewDevServerLogs}
className="gap-1"
>
<ScrollText className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View dev server logs</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{/* Git Operations */}
{selectedAttempt && branchStatus && !mergeInfo.hasMergedPR && (
<>
@@ -755,67 +680,69 @@ function CurrentAttempt({
</>
)}
{isStopping || isAttemptRunning ? (
<Button
variant="destructive"
size="xs"
onClick={stopAllExecutions}
disabled={isStopping}
className="gap-2"
>
<StopCircle className="h-4 w-4" />
{isStopping ? 'Stopping...' : 'Stop Attempt'}
</Button>
) : (
<Button
variant="outline"
size="xs"
onClick={handleEnterCreateAttemptMode}
className="gap-2"
>
<Plus className="h-4 w-4" />
New Attempt
</Button>
)}
{taskAttempts.length > 1 && (
<DropdownMenu>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="xs" className="gap-2">
<History className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>View attempt history</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="start" className="w-64">
{taskAttempts.map((attempt) => (
<DropdownMenuItem
key={attempt.id}
onClick={() => handleAttemptChange(attempt)}
className={
selectedAttempt?.id === attempt.id ? 'bg-accent' : ''
}
>
<div className="flex flex-col w-full">
<span className="font-medium text-sm">
{new Date(attempt.created_at).toLocaleDateString()}{' '}
{new Date(attempt.created_at).toLocaleTimeString()}
</span>
<span className="text-xs text-muted-foreground">
{attempt.profile || 'Base Agent'}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<div className="flex gap-2 @md:flex-none">
{isStopping || isAttemptRunning ? (
<Button
variant="destructive"
size="xs"
onClick={stopAllExecutions}
disabled={isStopping}
className="gap-1 flex-1"
>
<StopCircle className="h-4 w-4" />
{isStopping ? 'Stopping...' : 'Stop Attempt'}
</Button>
) : (
<Button
variant="outline"
size="xs"
onClick={handleEnterCreateAttemptMode}
className="gap-1 flex-1"
>
<Plus className="h-4 w-4" />
New Attempt
</Button>
)}
{taskAttempts.length > 1 && (
<DropdownMenu>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="xs" className="gap-1">
<History className="h-3 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>View attempt history</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="start" className="w-64">
{taskAttempts.map((attempt) => (
<DropdownMenuItem
key={attempt.id}
onClick={() => handleAttemptChange(attempt)}
className={
selectedAttempt?.id === attempt.id ? 'bg-accent' : ''
}
>
<div className="flex flex-col w-full">
<span className="font-medium text-sm">
{new Date(attempt.created_at).toLocaleDateString()}{' '}
{new Date(attempt.created_at).toLocaleTimeString()}
</span>
<span className="text-xs text-muted-foreground">
{attempt.profile || 'Base Agent'}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>

View File

@@ -19,52 +19,69 @@ export const PANEL_WIDTHS = {
} as const;
// Generate classes for TaskDetailsPanel
export const getTaskPanelClasses = () => {
const overlayClasses = [
'fixed inset-y-0 right-0 z-50',
PANEL_WIDTHS.base,
PANEL_WIDTHS.sm,
PANEL_WIDTHS.md,
PANEL_WIDTHS.lg,
PANEL_WIDTHS.xl,
].join(' ');
export const getTaskPanelClasses = (forceFullScreen: boolean) => {
const overlayClasses = forceFullScreen
? 'fixed inset-y-0 right-0 z-50 w-full'
: [
'fixed inset-y-0 right-0 z-50',
PANEL_WIDTHS.base,
PANEL_WIDTHS.sm,
PANEL_WIDTHS.md,
PANEL_WIDTHS.lg,
PANEL_WIDTHS.xl,
].join(' ');
const sideBySideClasses = [
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:relative`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:inset-auto`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:z-auto`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:h-full`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:w-[800px]`,
].join(' ');
const sideBySideClasses = forceFullScreen
? ''
: [
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:relative`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:inset-auto`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:z-auto`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:h-full`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:w-[800px]`,
].join(' ');
return `${overlayClasses} ${sideBySideClasses} bg-background border-l shadow-lg overflow-hidden`;
};
// Generate classes for backdrop (only show in overlay mode)
export const getBackdropClasses = () => {
return `fixed inset-0 z-40 bg-background/80 backdrop-blur-sm ${PANEL_SIDE_BY_SIDE_BREAKPOINT}:hidden`;
export const getBackdropClasses = (forceFullScreen: boolean) => {
return `fixed inset-0 z-40 bg-background/80 backdrop-blur-sm ${PANEL_SIDE_BY_SIDE_BREAKPOINT}:hidden ${forceFullScreen ? '' : 'hidden'}`;
};
// Generate classes for main container (enable flex layout in side-by-side mode)
export const getMainContainerClasses = (isPanelOpen: boolean) => {
if (!isPanelOpen) return 'w-full';
export const getMainContainerClasses = (
isPanelOpen: boolean,
forceFullScreen: boolean
) => {
const overlayClasses =
isPanelOpen && forceFullScreen
? 'w-full'
: `${PANEL_SIDE_BY_SIDE_BREAKPOINT}:flex ${PANEL_SIDE_BY_SIDE_BREAKPOINT}:h-full`;
return `w-full ${PANEL_SIDE_BY_SIDE_BREAKPOINT}:flex ${PANEL_SIDE_BY_SIDE_BREAKPOINT}:h-full`;
return `${overlayClasses}`;
};
// Generate classes for kanban section
export const getKanbanSectionClasses = (isPanelOpen: boolean) => {
export const getKanbanSectionClasses = (
isPanelOpen: boolean,
forceFullScreen: boolean
) => {
if (!isPanelOpen) return 'w-full';
const overlayClasses = 'w-full opacity-50 pointer-events-none';
const sideBySideClasses = [
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:flex-1`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:min-w-0`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:h-full`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:overflow-y-auto`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:opacity-100`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:pointer-events-auto`,
].join(' ');
// const overlayClasses = 'w-full opacity-50 pointer-events-none';
const sideBySideClasses =
isPanelOpen && forceFullScreen
? ''
: [
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:flex-1`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:min-w-0`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:h-full`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:overflow-y-auto`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:opacity-100`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:pointer-events-auto`,
].join(' ');
return `${overlayClasses} ${sideBySideClasses}`;
// return `${overlayClasses} ${sideBySideClasses}`;
return `${sideBySideClasses}`;
};

View File

@@ -83,6 +83,9 @@ export function ProjectTasks() {
setIsTaskDialogOpen(true);
}, []);
// Full screen
const [fullScreenTaskDetails, setFullScreenTaskDetails] = useState(false);
const handleOpenInIDE = useCallback(async () => {
if (!projectId) return;
@@ -380,9 +383,13 @@ export function ProjectTasks() {
}
return (
<div className={getMainContainerClasses(isPanelOpen)}>
<div
className={getMainContainerClasses(isPanelOpen, fullScreenTaskDetails)}
>
{/* Left Column - Kanban Section */}
<div className={getKanbanSectionClasses(isPanelOpen)}>
<div
className={getKanbanSectionClasses(isPanelOpen, fullScreenTaskDetails)}
>
{/* Header */}
<div className="px-8 my-12 flex flex-row">
@@ -527,6 +534,8 @@ export function ProjectTasks() {
onEditTask={handleEditTask}
onDeleteTask={handleDeleteTask}
isDialogOpen={isTaskDialogOpen || isProjectSettingsOpen}
isFullScreen={fullScreenTaskDetails}
setFullScreen={setFullScreenTaskDetails}
/>
)}

View File

@@ -121,5 +121,5 @@ module.exports = {
},
},
},
plugins: [require("tailwindcss-animate")],
plugins: [require("tailwindcss-animate"), require("@tailwindcss/container-queries")],
}