From 9e286b61e5f204adf3c48f4b19dc24c7b6ce5bc7 Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Wed, 27 Aug 2025 23:59:26 +0100 Subject: [PATCH] Overhaul UI (#577) * font * flat ui * burger menu * button styles * drag effects * search * Improve * navbar * task details header WIP * task attempt window actions * task details * split out title description component * follow up * better board spacing * Incrementally use tanstack (vibe-kanban 0c34261d) Let's refactor the codebase to remove: @frontend/src/components/context/TaskDetailsContextProvider.tsx @frontend/src/components/context/TaskDetailsContextProvider.ts Instead, we want to use @tanstack/react-query * task attempt header info * ui for dropdown * optionally disable * Create hook for attempt actions (vibe-kanban 651551d9) - Start dev server - Rebase - Create PR - Merge These should all be hooks, similar to frontend/src/hooks/useOpenInEditor.ts Their usage in two places should be standardised: - frontend/src/components/tasks/AttemptHeaderCard.tsx - frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx * dropdown positioning * color * soften colours * add new task button * editor dialog via hook * project provider * fmt * lint * follow up styling * break words * card styles * Stop executions from follow up (vibe-kanban e2a2c75b) The follow up section currently disables the 'send' button if a task attempt is running, however instead we should show a destructive 'stop' button which will perform the same functionality as 'stop attempt' frontend/src/components/tasks/TaskFollowUpSection.tsx You can see how we stop already in frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx Maybe we could make this a hook and use tanstack similar to frontend/src/hooks/useBranchStatus.ts What about making the hook more generic, to cover start/stop and status retrieval. We should also combine the hook frontend/src/hooks/useExecutionProcesses.ts * Make sure the kanban columns are always at least full height (vibe-kanban 220cb780) There can be whitespace underneath the columns, ideally there should be no whitespace - the columns should extend to the bottom of the page, even when there aren't enough tasks to fill it up all the way ![Screenshot 2025-08-27 at 14.42.41.png](.vibe-images/11efe690-ec72-4513-a7b6-49641ff170c4.png) frontend/src/pages/project-tasks.tsx * Display diff summary (vibe-kanban f1736551) If files have been changed, we should display a summary of the changes like "6 files changed, +21 -19" in the AttemptHeaderCard, to the right of the dropdown, similar to how we do at the top of the difftab. We should also add an icon button to open the task attempt in full screen and at the diff tab. frontend/src/components/tasks/AttemptHeaderCard.tsx frontend/src/components/tasks/TaskDetails/DiffTab.tsx * styles * projects * full screen max width * full screen actions * remove log * style improve * create new attempt * darkmode * scroll diffs * Refactor useCreatePR (vibe-kanban e6b76f10) The useCreatePR hook should function similarly to useOpenInEditor, in that the the popup should be rendered in some root node. This improves the reusability of this functionality. We should then update TaskDetailsPanel to make the 'create pr' button real. frontend/src/hooks/useOpenInEditor.ts frontend/src/hooks/useCreatePR.ts frontend/src/components/tasks/TaskDetailsPanel.tsx * Rebasing should cause branch status to refresh (vibe-kanban 3da4fe0f) Currently doesn't in frontend/src/components/tasks/TaskDetailsPanel.tsx * project name * Change ?view=full to /full (vibe-kanban a25483a6) * Hide TaskDetailsHeader (vibe-kanban b73697bd) If the app is running inside of VS Code * copy * Add button to open repo (vibe-kanban e447df94) Open repo in IDE button in the navbar, next to create task button * style process cards * Errors not displayed properly (vibe-kanban fb65eb03) frontend/src/components/tasks/TaskDetailsToolbar.tsx Errors are currently failing silently on actions like merge and rebase * fmt * fix * fix border --- frontend/index.html | 3 + frontend/package-lock.json | 55 +++ frontend/package.json | 4 +- frontend/src/App.tsx | 131 +++--- frontend/src/components/DiffCard.tsx | 13 +- .../context/TaskDetailsContextProvider.tsx | 279 ------------- .../components/context/taskDetailsContext.ts | 83 ---- frontend/src/components/layout/navbar.tsx | 195 ++++++--- frontend/src/components/logo.tsx | 2 +- .../src/components/logs/ProcessStartCard.tsx | 2 +- .../src/components/projects/ProjectCard.tsx | 2 +- .../src/components/projects/project-list.tsx | 2 +- frontend/src/components/search-bar.tsx | 64 +++ .../components/tasks/AttemptHeaderCard.tsx | 143 +++++++ .../tasks/DeleteFileConfirmationDialog.tsx | 37 +- .../tasks/EditorSelectionDialog.tsx | 10 +- frontend/src/components/tasks/TaskCard.tsx | 116 +++--- .../components/tasks/TaskDetails/DiffTab.tsx | 56 +-- .../components/tasks/TaskDetails/LogsTab.tsx | 27 +- .../tasks/TaskDetails/ProcessesTab.tsx | 39 +- .../tasks/TaskDetails/TabNavigation.tsx | 34 +- .../TaskDetails/TaskTitleDescription.tsx | 60 +++ .../components/tasks/TaskDetailsHeader.tsx | 298 +++++-------- .../src/components/tasks/TaskDetailsPanel.tsx | 259 ++++++------ .../components/tasks/TaskDetailsToolbar.tsx | 299 ++++---------- .../components/tasks/TaskFollowUpSection.tsx | 354 +++++++++------- .../tasks/TaskFormDialogContainer.tsx | 137 ++++++ .../src/components/tasks/TaskKanbanBoard.tsx | 17 +- frontend/src/components/tasks/TodoPanel.tsx | 22 +- .../tasks/Toolbar/CreateAttempt.tsx | 50 +-- .../tasks/Toolbar/CreatePRDialog.tsx | 115 +++--- .../tasks/Toolbar/CurrentAttempt.tsx | 173 +++----- .../components/ui/auto-expanding-textarea.tsx | 2 +- frontend/src/components/ui/button.tsx | 10 +- frontend/src/components/ui/card.tsx | 5 +- frontend/src/components/ui/input.tsx | 2 +- .../components/ui/shadcn-io/kanban/index.tsx | 38 +- .../src/contexts/create-pr-dialog-context.tsx | 71 ++++ .../src/contexts/editor-dialog-context.tsx | 65 +++ frontend/src/contexts/project-context.tsx | 59 +++ frontend/src/contexts/search-context.tsx | 63 +++ frontend/src/contexts/task-dialog-context.tsx | 144 +++++++ frontend/src/hooks/index.ts | 10 + frontend/src/hooks/useAttemptCreation.ts | 46 +++ frontend/src/hooks/useAttemptExecution.ts | 121 ++++++ frontend/src/hooks/useBranchStatus.ts | 11 + frontend/src/hooks/useCreatePR.ts | 30 ++ frontend/src/hooks/useDevServer.ts | 77 ++++ frontend/src/hooks/useDiffSummary.ts | 45 ++ frontend/src/hooks/useExecutionProcesses.ts | 27 ++ frontend/src/hooks/useMerge.ts | 30 ++ frontend/src/hooks/useOpenInEditor.ts | 39 ++ frontend/src/hooks/useProjectBranches.ts | 12 + frontend/src/hooks/usePush.ts | 26 ++ frontend/src/hooks/useRebase.ts | 40 ++ frontend/src/lib/responsive-config.ts | 19 +- frontend/src/main.tsx | 21 +- frontend/src/pages/project-tasks.tsx | 391 +++++------------- frontend/src/pages/task-details.tsx | 131 ------ frontend/src/stores/useTaskDetailsUiStore.ts | 96 +++++ frontend/src/styles/index.css | 44 +- frontend/src/utils/status-labels.ts | 17 + frontend/src/vscode/bridge.ts | 2 +- frontend/tailwind.config.js | 13 + pnpm-lock.yaml | 38 ++ 65 files changed, 2811 insertions(+), 2015 deletions(-) delete mode 100644 frontend/src/components/context/TaskDetailsContextProvider.tsx delete mode 100644 frontend/src/components/context/taskDetailsContext.ts create mode 100644 frontend/src/components/search-bar.tsx create mode 100644 frontend/src/components/tasks/AttemptHeaderCard.tsx create mode 100644 frontend/src/components/tasks/TaskDetails/TaskTitleDescription.tsx create mode 100644 frontend/src/components/tasks/TaskFormDialogContainer.tsx create mode 100644 frontend/src/contexts/create-pr-dialog-context.tsx create mode 100644 frontend/src/contexts/editor-dialog-context.tsx create mode 100644 frontend/src/contexts/project-context.tsx create mode 100644 frontend/src/contexts/search-context.tsx create mode 100644 frontend/src/contexts/task-dialog-context.tsx create mode 100644 frontend/src/hooks/index.ts create mode 100644 frontend/src/hooks/useAttemptCreation.ts create mode 100644 frontend/src/hooks/useAttemptExecution.ts create mode 100644 frontend/src/hooks/useBranchStatus.ts create mode 100644 frontend/src/hooks/useCreatePR.ts create mode 100644 frontend/src/hooks/useDevServer.ts create mode 100644 frontend/src/hooks/useDiffSummary.ts create mode 100644 frontend/src/hooks/useExecutionProcesses.ts create mode 100644 frontend/src/hooks/useMerge.ts create mode 100644 frontend/src/hooks/useOpenInEditor.ts create mode 100644 frontend/src/hooks/useProjectBranches.ts create mode 100644 frontend/src/hooks/usePush.ts create mode 100644 frontend/src/hooks/useRebase.ts delete mode 100644 frontend/src/pages/task-details.tsx create mode 100644 frontend/src/stores/useTaskDetailsUiStore.ts create mode 100644 frontend/src/utils/status-labels.ts diff --git a/frontend/index.html b/frontend/index.html index e0c5333d..db738fb8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,6 +9,9 @@ + + + vibe-kanban diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 00854ca7..43f02b4e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,6 +28,8 @@ "@sentry/react": "^9.34.0", "@sentry/vite-plugin": "^3.5.0", "@tailwindcss/typography": "^0.5.16", + "@tanstack/react-query": "^5.85.5", + "@tanstack/react-query-devtools": "^5.85.5", "@types/react-window": "^1.8.8", "@uiw/react-codemirror": "^4.25.1", "class-variance-authority": "^0.7.0", @@ -2960,6 +2962,59 @@ "node": ">=4" } }, + "node_modules/@tanstack/query-core": { + "version": "5.85.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.5.tgz", + "integrity": "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.84.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.84.0.tgz", + "integrity": "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.85.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.5.tgz", + "integrity": "sha512-/X4EFNcnPiSs8wM2v+b6DqS5mmGeuJQvxBglmDxl6ZQb5V26ouD2SJYAcC3VjbNwqhY2zjxVD15rDA5nGbMn3A==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.85.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.85.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.85.5.tgz", + "integrity": "sha512-6Ol6Q+LxrCZlQR4NoI5181r+ptTwnlPG2t7H9Sp3klxTBhYGunONqcgBn2YKRPsaKiYM8pItpKMdMXMEINntMQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.84.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.85.5", + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 81c344a6..1039539e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,8 @@ "@sentry/react": "^9.34.0", "@sentry/vite-plugin": "^3.5.0", "@tailwindcss/typography": "^0.5.16", + "@tanstack/react-query": "^5.85.5", + "@tanstack/react-query-devtools": "^5.85.5", "@types/react-window": "^1.8.8", "@uiw/react-codemirror": "^4.25.1", "class-variance-authority": "^0.7.0", @@ -75,4 +77,4 @@ "typescript": "^5.9.2", "vite": "^5.0.8" } -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ec4c87f2..6254f574 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,6 @@ import { BrowserRouter, Route, Routes, useLocation } from 'react-router-dom'; import { Navbar } from '@/components/layout/navbar'; import { Projects } from '@/pages/projects'; import { ProjectTasks } from '@/pages/project-tasks'; -import { TaskDetailsPage } from '@/pages/task-details'; import { Settings } from '@/pages/Settings'; import { McpServers } from '@/pages/McpServers'; @@ -12,6 +11,17 @@ import { OnboardingDialog } from '@/components/OnboardingDialog'; import { PrivacyOptInDialog } from '@/components/PrivacyOptInDialog'; import { ConfigProvider, useConfig } from '@/components/config-provider'; import { ThemeProvider } from '@/components/theme-provider'; +import { SearchProvider } from '@/contexts/search-context'; +import { + EditorDialogProvider, + useEditorDialog, +} from '@/contexts/editor-dialog-context'; +import { CreatePRDialogProvider } from '@/contexts/create-pr-dialog-context'; +import { EditorSelectionDialog } from '@/components/tasks/EditorSelectionDialog'; +import CreatePRDialog from '@/components/tasks/Toolbar/CreatePRDialog'; +import { TaskDialogProvider } from '@/contexts/task-dialog-context'; +import { TaskFormDialogContainer } from '@/components/tasks/TaskFormDialogContainer'; +import { ProjectProvider } from '@/contexts/project-context'; import type { EditorType, ProfileVariantLabel } from 'shared/types'; import { ThemeMode } from 'shared/types'; import { configApi } from '@/lib/api'; @@ -26,6 +36,11 @@ const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); function AppContent() { const { config, updateConfig, loading } = useConfig(); const location = useLocation(); + const { + isOpen: editorDialogOpen, + selectedAttempt, + closeEditorDialog, + } = useEditorDialog(); const [showDisclaimer, setShowDisclaimer] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false); const [showPrivacyOptIn, setShowPrivacyOptIn] = useState(false); @@ -139,57 +154,61 @@ function AppContent() { return ( -
- {/* Custom context menu and VS Code-friendly interactions when embedded in iframe */} - - - - - - {showNavbar && } -
- - } /> - } /> - } /> - } - /> - } - /> - } - /> - } - /> - } - /> - - } /> - } /> - + +
+ {/* Custom context menu and VS Code-friendly interactions when embedded in iframe */} + + + + + + + + + {showNavbar && } +
+ + } /> + } /> + } /> + } + /> + } + /> + } + /> + } + /> + } /> + } /> + +
-
+ ); @@ -199,7 +218,15 @@ function App() { return ( - + + + + + + + + + ); diff --git a/frontend/src/components/DiffCard.tsx b/frontend/src/components/DiffCard.tsx index 94d05744..833f1576 100644 --- a/frontend/src/components/DiffCard.tsx +++ b/frontend/src/components/DiffCard.tsx @@ -1,7 +1,7 @@ import { Diff as Diff, ThemeMode } from 'shared/types'; import { DiffModeEnum, DiffView } from '@git-diff-view/react'; import { generateDiffFile } from '@git-diff-view/file'; -import { useMemo, useContext } from 'react'; +import { useMemo } from 'react'; import { useConfig } from '@/components/config-provider'; import { getHighLightLanguageFromPath } from '@/utils/extToLanguage'; import { Button } from '@/components/ui/button'; @@ -16,13 +16,14 @@ import { Key, } from 'lucide-react'; import '@/styles/diff-style-overrides.css'; -import { TaskSelectedAttemptContext } from '@/components/context/taskDetailsContext'; import { attemptsApi } from '@/lib/api'; +import type { TaskAttempt } from 'shared/types'; type Props = { diff: Diff; expanded: boolean; onToggle: () => void; + selectedAttempt: TaskAttempt | null; }; function labelAndIcon(diff: Diff) { @@ -37,9 +38,13 @@ function labelAndIcon(diff: Diff) { return { label: undefined as string | undefined, Icon: PencilLine }; } -export default function DiffCard({ diff, expanded, onToggle }: Props) { +export default function DiffCard({ + diff, + expanded, + onToggle, + selectedAttempt, +}: Props) { const { config } = useConfig(); - const { selectedAttempt } = useContext(TaskSelectedAttemptContext); const theme = config?.theme === ThemeMode.DARK ? 'dark' : 'light'; const oldName = diff.oldPath || undefined; diff --git a/frontend/src/components/context/TaskDetailsContextProvider.tsx b/frontend/src/components/context/TaskDetailsContextProvider.tsx deleted file mode 100644 index 7a5b39fd..00000000 --- a/frontend/src/components/context/TaskDetailsContextProvider.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import { - Dispatch, - FC, - ReactNode, - SetStateAction, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; -import type { ExecutionProcess } from 'shared/types'; -import type { - EditorType, - TaskAttempt, - TaskWithAttemptStatus, - BranchStatus, -} from 'shared/types'; -import { attemptsApi, executionProcessesApi } from '@/lib/api.ts'; -import { - TaskAttemptDataContext, - TaskAttemptLoadingContext, - TaskAttemptStoppingContext, - TaskDeletingFilesContext, - TaskDetailsContext, - TaskSelectedAttemptContext, -} from './taskDetailsContext.ts'; -import type { AttemptData } from '@/lib/types.ts'; -import { useUserSystem } from '@/components/config-provider'; - -const TaskDetailsProvider: FC<{ - task: TaskWithAttemptStatus; - projectId: string; - children: ReactNode; - setShowEditorDialog: Dispatch>; - projectHasDevScript?: boolean; -}> = ({ - task, - projectId, - children, - setShowEditorDialog, - projectHasDevScript, -}) => { - const { profiles } = useUserSystem(); - const [loading, setLoading] = useState(false); - const [isStopping, setIsStopping] = useState(false); - const [selectedAttempt, setSelectedAttempt] = useState( - null - ); - const [deletingFiles, setDeletingFiles] = useState>(new Set()); - const [fileToDelete, setFileToDelete] = useState(null); - - const [attemptData, setAttemptData] = useState({ - processes: [], - runningProcessDetails: {}, - }); - const [branchStatus, setBranchStatus] = useState(null); - - const handleOpenInEditor = useCallback( - async (editorType?: EditorType) => { - if (!task || !selectedAttempt) return; - - try { - const result = await attemptsApi.openEditor( - selectedAttempt.id, - editorType - ); - - if (result === undefined && !editorType) { - setShowEditorDialog(true); - } - } catch (err) { - console.error('Failed to open editor:', err); - if (!editorType) { - setShowEditorDialog(true); - } - } - }, - [task, projectId, selectedAttempt, setShowEditorDialog] - ); - - const fetchAttemptData = useCallback( - async (attemptId: string) => { - if (!task) return; - - try { - const processesResult = - await executionProcessesApi.getExecutionProcesses(attemptId); - - if (processesResult !== undefined) { - const runningProcessDetails: Record = {}; - - // Also fetch setup script process details if it exists in the processes - const setupProcess = processesResult.find( - (process) => process.run_reason === 'setupscript' - ); - if (setupProcess && !runningProcessDetails[setupProcess.id]) { - const result = await executionProcessesApi.getDetails( - setupProcess.id - ); - - if (result !== undefined) { - runningProcessDetails[setupProcess.id] = result; - // Extract ProfileVariant from the executor_action - } - } - - setAttemptData((prev: AttemptData) => { - const newData = { - processes: processesResult, - runningProcessDetails, - }; - if (JSON.stringify(prev) === JSON.stringify(newData)) return prev; - return newData; - }); - } - - // Also fetch branch status as part of attempt data - try { - const branchResult = await attemptsApi.getBranchStatus(attemptId); - setBranchStatus(branchResult); - } catch (err) { - console.error('Failed to fetch branch status:', err); - setBranchStatus(null); - } - } catch (err) { - console.error('Failed to fetch attempt data:', err); - } - }, - [task, projectId] - ); - - useEffect(() => { - if (selectedAttempt && task) { - fetchAttemptData(selectedAttempt.id); - } - }, [selectedAttempt, task, fetchAttemptData]); - - const isAttemptRunning = useMemo(() => { - if (!selectedAttempt || isStopping) { - return false; - } - - return attemptData.processes.some( - (process: ExecutionProcess) => - (process.run_reason === 'codingagent' || - process.run_reason === 'setupscript' || - process.run_reason === 'cleanupscript') && - process.status === 'running' - ); - }, [selectedAttempt, attemptData.processes, isStopping]); - - const defaultFollowUpVariant = useMemo(() => { - // Find most recent coding agent process with variant - const latest_profile = attemptData.processes - .filter((p) => p.run_reason === 'codingagent') - .reverse() - .map((process) => { - if ( - process.executor_action?.typ.type === 'CodingAgentInitialRequest' || - process.executor_action?.typ.type === 'CodingAgentFollowUpRequest' - ) { - return process.executor_action?.typ.profile_variant_label; - } - })[0]; - if (latest_profile) { - return latest_profile.variant; - } - if (selectedAttempt?.profile && profiles) { - // No processes yet, check if profile has default variant - const profile = profiles.find((p) => p.label === selectedAttempt.profile); - if (profile?.variants && profile.variants.length > 0) { - return profile.variants[0].label; - } - } - return null; - }, [attemptData.processes, selectedAttempt?.profile, profiles]); - - useEffect(() => { - const interval = setInterval(() => { - if (selectedAttempt) { - fetchAttemptData(selectedAttempt.id); - } - }, 5000); - - return () => clearInterval(interval); - }, [isAttemptRunning, task, selectedAttempt, fetchAttemptData]); - - // Fetch branch status when selected attempt changes - useEffect(() => { - if (!selectedAttempt) { - setBranchStatus(null); - return; - } - - const fetchBranchStatus = async () => { - try { - const result = await attemptsApi.getBranchStatus(selectedAttempt.id); - setBranchStatus(result); - } catch (err) { - console.error('Failed to fetch branch status:', err); - setBranchStatus(null); - } - }; - - fetchBranchStatus(); - }, [selectedAttempt]); - - const value = useMemo( - () => ({ - task, - projectId, - handleOpenInEditor, - projectHasDevScript, - }), - [task, projectId, handleOpenInEditor, projectHasDevScript] - ); - - const taskAttemptLoadingValue = useMemo( - () => ({ loading, setLoading }), - [loading] - ); - - const selectedAttemptValue = useMemo( - () => ({ selectedAttempt, setSelectedAttempt }), - [selectedAttempt] - ); - - const attemptStoppingValue = useMemo( - () => ({ isStopping, setIsStopping }), - [isStopping] - ); - - const deletingFilesValue = useMemo( - () => ({ - deletingFiles, - fileToDelete, - setFileToDelete, - setDeletingFiles, - }), - [deletingFiles, fileToDelete] - ); - - const attemptDataValue = useMemo( - () => ({ - attemptData, - setAttemptData, - fetchAttemptData, - isAttemptRunning, - defaultFollowUpVariant, - branchStatus, - setBranchStatus, - }), - [ - attemptData, - fetchAttemptData, - isAttemptRunning, - defaultFollowUpVariant, - branchStatus, - ] - ); - - return ( - - - - - - - {children} - - - - - - - ); -}; - -export default TaskDetailsProvider; diff --git a/frontend/src/components/context/taskDetailsContext.ts b/frontend/src/components/context/taskDetailsContext.ts deleted file mode 100644 index 6dfc4347..00000000 --- a/frontend/src/components/context/taskDetailsContext.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { createContext, Dispatch, SetStateAction } from 'react'; -import type { - EditorType, - TaskAttempt, - TaskWithAttemptStatus, - BranchStatus, -} from 'shared/types'; -import { AttemptData } from '@/lib/types.ts'; - -export interface TaskDetailsContextValue { - task: TaskWithAttemptStatus; - projectId: string; - handleOpenInEditor: (editorType?: EditorType) => Promise; - projectHasDevScript?: boolean; -} - -export const TaskDetailsContext = createContext( - {} as TaskDetailsContextValue -); - -interface TaskAttemptLoadingContextValue { - loading: boolean; - setLoading: Dispatch>; -} - -export const TaskAttemptLoadingContext = - createContext( - {} as TaskAttemptLoadingContextValue - ); - -interface TaskAttemptDataContextValue { - attemptData: AttemptData; - setAttemptData: Dispatch>; - fetchAttemptData: (attemptId: string) => Promise | void; - isAttemptRunning: boolean; - defaultFollowUpVariant: string | null; - branchStatus: BranchStatus | null; - setBranchStatus: Dispatch>; -} - -export const TaskAttemptDataContext = - createContext({} as TaskAttemptDataContextValue); - -interface TaskSelectedAttemptContextValue { - selectedAttempt: TaskAttempt | null; - setSelectedAttempt: Dispatch>; -} - -export const TaskSelectedAttemptContext = - createContext( - {} as TaskSelectedAttemptContextValue - ); - -interface TaskAttemptStoppingContextValue { - isStopping: boolean; - setIsStopping: Dispatch>; -} - -export const TaskAttemptStoppingContext = - createContext( - {} as TaskAttemptStoppingContextValue - ); - -interface TaskDeletingFilesContextValue { - deletingFiles: Set; - setDeletingFiles: Dispatch>>; - fileToDelete: string | null; - setFileToDelete: Dispatch>; -} - -export const TaskDeletingFilesContext = - createContext( - {} as TaskDeletingFilesContextValue - ); - -interface TaskBackgroundRefreshContextValue { - isBackgroundRefreshing: boolean; -} - -export const TaskBackgroundRefreshContext = - createContext( - {} as TaskBackgroundRefreshContextValue - ); diff --git a/frontend/src/components/layout/navbar.tsx b/frontend/src/components/layout/navbar.tsx index 3aa3e4b4..86340793 100644 --- a/frontend/src/components/layout/navbar.tsx +++ b/frontend/src/components/layout/navbar.tsx @@ -1,83 +1,150 @@ import { Link, useLocation } from 'react-router-dom'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { FolderOpen, Settings, BookOpen, Server, MessageCircleQuestion, + Menu, + Plus, } from 'lucide-react'; import { Logo } from '@/components/logo'; +import { SearchBar } from '@/components/search-bar'; +import { useSearch } from '@/contexts/search-context'; +import { useTaskDialog } from '@/contexts/task-dialog-context'; +import { useProject } from '@/contexts/project-context'; +import { projectsApi } from '@/lib/api'; + +const INTERNAL_NAV = [ + { label: 'Projects', icon: FolderOpen, to: '/projects' }, + { label: 'MCP Servers', icon: Server, to: '/mcp-servers' }, + { label: 'Settings', icon: Settings, to: '/settings' }, +]; + +const EXTERNAL_LINKS = [ + { + label: 'Docs', + icon: BookOpen, + href: 'https://vibekanban.com/', + }, + { + label: 'Support', + icon: MessageCircleQuestion, + href: 'https://github.com/BloopAI/vibe-kanban/issues', + }, +]; export function Navbar() { const location = useLocation(); + const { projectId, project } = useProject(); + const { query, setQuery, active, clear } = useSearch(); + const { openCreate } = useTaskDialog(); + + const handleOpenInIDE = async () => { + if (!projectId) return; + try { + await projectsApi.openEditor(projectId); + } catch (err) { + console.error('Failed to open project in IDE:', err); + } + }; return ( -
-
-
-
- -
- - - -
+
+
+
+
+ + +
-
- - + + + +
+ {projectId && ( + <> + + + + )} + + + + + + + {INTERNAL_NAV.map((item) => { + const active = location.pathname === item.to; + const Icon = item.icon; + return ( + + + + {item.label} + + + ); + })} + + + + {EXTERNAL_LINKS.map((item) => { + const Icon = item.icon; + return ( + + + + {item.label} + + + ); + })} + +
diff --git a/frontend/src/components/logo.tsx b/frontend/src/components/logo.tsx index 6e630512..443092a6 100644 --- a/frontend/src/components/logo.tsx +++ b/frontend/src/components/logo.tsx @@ -32,7 +32,7 @@ export function Logo({ className = '' }: { className?: string }) { return (
navigate(`/projects/${project.id}/tasks`)} tabIndex={isFocused ? 0 : -1} ref={ref} diff --git a/frontend/src/components/projects/project-list.tsx b/frontend/src/components/projects/project-list.tsx index 7e1884bb..8778f5e3 100644 --- a/frontend/src/components/projects/project-list.tsx +++ b/frontend/src/components/projects/project-list.tsx @@ -128,7 +128,7 @@ export function ProjectList() { }, []); return ( -
+

Projects

diff --git a/frontend/src/components/search-bar.tsx b/frontend/src/components/search-bar.tsx new file mode 100644 index 00000000..c354a04e --- /dev/null +++ b/frontend/src/components/search-bar.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { Search } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; +import { Project } from 'shared/types'; + +interface SearchBarProps { + className?: string; + value?: string; + onChange?: (value: string) => void; + disabled?: boolean; + onClear?: () => void; + project: Project | null; +} + +export function SearchBar({ + className, + value = '', + onChange, + disabled = false, + onClear, + project, +}: SearchBarProps) { + const inputRef = React.useRef(null); + + React.useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { + e.preventDefault(); + inputRef.current?.focus(); + } + + if (e.key === 'Escape' && document.activeElement === inputRef.current) { + e.preventDefault(); + onClear?.(); + inputRef.current?.blur(); + } + } + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [onClear]); + + if (disabled) { + return null; + } + + return ( +
+ + onChange?.(e.target.value)} + disabled={disabled} + placeholder={project ? `Search ${project.name}...` : 'Search...'} + className="pl-8 pr-14 h-8" + /> + + ⌘S + +
+ ); +} diff --git a/frontend/src/components/tasks/AttemptHeaderCard.tsx b/frontend/src/components/tasks/AttemptHeaderCard.tsx new file mode 100644 index 00000000..f8f1769a --- /dev/null +++ b/frontend/src/components/tasks/AttemptHeaderCard.tsx @@ -0,0 +1,143 @@ +import { Card } from '../ui/card'; +import { Button } from '../ui/button'; +import { MoreHorizontal } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; +import type { TaskAttempt, TaskWithAttemptStatus } from 'shared/types'; +import { useDevServer } from '@/hooks/useDevServer'; +import { useRebase } from '@/hooks/useRebase'; +import { useMerge } from '@/hooks/useMerge'; +import { useOpenInEditor } from '@/hooks/useOpenInEditor'; +import { useDiffSummary } from '@/hooks/useDiffSummary'; +import { useCreatePRDialog } from '@/contexts/create-pr-dialog-context'; + +interface AttemptHeaderCardProps { + attemptNumber: number; + totalAttempts: number; + selectedAttempt: TaskAttempt | null; + task: TaskWithAttemptStatus; + projectId: string; + // onCreateNewAttempt?: () => void; + onJumpToDiffFullScreen?: () => void; +} + +export function AttemptHeaderCard({ + attemptNumber, + totalAttempts, + selectedAttempt, + task, + projectId, + // onCreateNewAttempt, + onJumpToDiffFullScreen, +}: AttemptHeaderCardProps) { + const { + start: startDevServer, + stop: stopDevServer, + runningDevServer, + } = useDevServer(selectedAttempt?.id); + const rebaseMutation = useRebase(selectedAttempt?.id, projectId); + const mergeMutation = useMerge(selectedAttempt?.id); + const openInEditor = useOpenInEditor(selectedAttempt); + const { fileCount, added, deleted } = useDiffSummary( + selectedAttempt?.id ?? null + ); + const { showCreatePRDialog } = useCreatePRDialog(); + + const handleCreatePR = () => { + if (selectedAttempt) { + showCreatePRDialog({ + attempt: selectedAttempt, + task, + projectId, + }); + } + }; + + return ( + +
+

+ Attempt ·{' '} + + {attemptNumber}/{totalAttempts} + +

+

+ Profile ·{' '} + {selectedAttempt?.profile} +

+ {selectedAttempt?.branch && ( +

+ Branch ·{' '} + {selectedAttempt.branch} +

+ )} + {fileCount > 0 && ( +

+ {' '} + · +{added}{' '} + -{deleted} +

+ )} +
+ + + + + + openInEditor()} + disabled={!selectedAttempt} + > + Open in IDE + + + {runningDevServer ? 'Stop dev server' : 'Start dev server'} + + rebaseMutation.mutate(undefined)} + disabled={!selectedAttempt} + > + Rebase + + + Create PR + + mergeMutation.mutate()} + disabled={!selectedAttempt} + > + Merge + + {/* + Create new attempt + */} + + +
+ ); +} diff --git a/frontend/src/components/tasks/DeleteFileConfirmationDialog.tsx b/frontend/src/components/tasks/DeleteFileConfirmationDialog.tsx index e3041d51..68e8a195 100644 --- a/frontend/src/components/tasks/DeleteFileConfirmationDialog.tsx +++ b/frontend/src/components/tasks/DeleteFileConfirmationDialog.tsx @@ -8,36 +8,37 @@ import { } from '@/components/ui/dialog.tsx'; import { Button } from '@/components/ui/button.tsx'; import { attemptsApi } from '@/lib/api.ts'; -import { useContext } from 'react'; -import { - TaskDeletingFilesContext, - TaskDetailsContext, - TaskSelectedAttemptContext, -} from '@/components/context/taskDetailsContext.ts'; +import { useTaskDeletingFiles } from '@/stores/useTaskDetailsUiStore'; +import type { Task, TaskAttempt } from 'shared/types'; -function DeleteFileConfirmationDialog() { - const { task, projectId } = useContext(TaskDetailsContext); - const { selectedAttempt } = useContext(TaskSelectedAttemptContext); +type Props = { + task: Task; + projectId: string; + selectedAttempt: TaskAttempt | null; +}; + +function DeleteFileConfirmationDialog({ + task, + projectId, + selectedAttempt, +}: Props) { const { setDeletingFiles, fileToDelete, deletingFiles, setFileToDelete } = - useContext(TaskDeletingFilesContext); + useTaskDeletingFiles(task.id); const handleConfirmDelete = async () => { if (!fileToDelete || !projectId || !task?.id || !selectedAttempt?.id) return; - setDeletingFiles((prev) => new Set(prev).add(fileToDelete)); + setDeletingFiles(new Set([...deletingFiles, fileToDelete])); try { await attemptsApi.deleteFile(selectedAttempt.id, fileToDelete); } catch (error: unknown) { - // @ts-expect-error it is type ApiError - setDiffError(error.message || 'Failed to delete file'); + console.error('Failed to delete file:', error); } finally { - setDeletingFiles((prev) => { - const newSet = new Set(prev); - newSet.delete(fileToDelete); - return newSet; - }); + const newSet = new Set(deletingFiles); + newSet.delete(fileToDelete); + setDeletingFiles(newSet); setFileToDelete(null); } }; diff --git a/frontend/src/components/tasks/EditorSelectionDialog.tsx b/frontend/src/components/tasks/EditorSelectionDialog.tsx index 088ae504..93e76d81 100644 --- a/frontend/src/components/tasks/EditorSelectionDialog.tsx +++ b/frontend/src/components/tasks/EditorSelectionDialog.tsx @@ -1,4 +1,4 @@ -import { useContext, useState } from 'react'; +import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -15,19 +15,21 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { EditorType } from 'shared/types'; -import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts'; +import { EditorType, TaskAttempt } from 'shared/types'; +import { useOpenInEditor } from '@/hooks/useOpenInEditor'; interface EditorSelectionDialogProps { isOpen: boolean; onClose: () => void; + selectedAttempt: TaskAttempt | null; } export function EditorSelectionDialog({ isOpen, onClose, + selectedAttempt, }: EditorSelectionDialogProps) { - const { handleOpenInEditor } = useContext(TaskDetailsContext); + const handleOpenInEditor = useOpenInEditor(selectedAttempt, onClose); const [selectedEditor, setSelectedEditor] = useState( EditorType.VS_CODE ); diff --git a/frontend/src/components/tasks/TaskCard.tsx b/frontend/src/components/tasks/TaskCard.tsx index 6e11866a..2a1ee555 100644 --- a/frontend/src/components/tasks/TaskCard.tsx +++ b/frontend/src/components/tasks/TaskCard.tsx @@ -75,70 +75,64 @@ export function TaskCard({ forwardedRef={localRef} onKeyDown={handleKeyDown} > -
-
-
-
-

{task.title}

-
-
-
- {/* In Progress Spinner */} - {task.has_in_progress_attempt && ( - - )} - {/* Merged Indicator */} - {task.has_merged_attempt && ( - - )} - {/* Failed Indicator */} - {task.last_attempt_failed && !task.has_merged_attempt && ( - - )} - {/* Actions Menu */} -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - - - - - - onEdit(task)}> - - Edit - - onDelete(task.id)} - className="text-destructive" - > - - Delete - - - -
+
+

+ {task.title} +

+
+ {/* In Progress Spinner */} + {task.has_in_progress_attempt && ( + + )} + {/* Merged Indicator */} + {task.has_merged_attempt && ( + + )} + {/* Failed Indicator */} + {task.last_attempt_failed && !task.has_merged_attempt && ( + + )} + {/* Actions Menu */} +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + + + + + onEdit(task)}> + + Edit + + onDelete(task.id)} + className="text-destructive" + > + + Delete + + +
- {task.description && ( -
-

- {task.description.length > 130 - ? `${task.description.substring(0, 130)}...` - : task.description} -

-
- )}
+ {task.description && ( +

+ {task.description.length > 130 + ? `${task.description.substring(0, 130)}...` + : task.description} +

+ )} ); } diff --git a/frontend/src/components/tasks/TaskDetails/DiffTab.tsx b/frontend/src/components/tasks/TaskDetails/DiffTab.tsx index 2fb413ef..27d97631 100644 --- a/frontend/src/components/tasks/TaskDetails/DiffTab.tsx +++ b/frontend/src/components/tasks/TaskDetails/DiffTab.tsx @@ -1,17 +1,22 @@ import { useDiffEntries } from '@/hooks/useDiffEntries'; -import { useMemo, useContext, useCallback, useState, useEffect } from 'react'; -import { TaskSelectedAttemptContext } from '@/components/context/taskDetailsContext.ts'; +import { useMemo, useCallback, useState, useEffect } from 'react'; import { Loader } from '@/components/ui/loader'; import { Button } from '@/components/ui/button'; import DiffCard from '@/components/DiffCard'; -import { generateDiffFile } from '@git-diff-view/file'; -import { getHighLightLanguageFromPath } from '@/utils/extToLanguage'; +import { useDiffSummary } from '@/hooks/useDiffSummary'; +import type { TaskAttempt } from 'shared/types'; -function DiffTab() { - const { selectedAttempt } = useContext(TaskSelectedAttemptContext); +interface DiffTabProps { + selectedAttempt: TaskAttempt | null; +} + +function DiffTab({ selectedAttempt }: DiffTabProps) { const [loading, setLoading] = useState(true); const [collapsedIds, setCollapsedIds] = useState>(new Set()); const { diffs, error } = useDiffEntries(selectedAttempt?.id ?? null, true); + const { fileCount, added, deleted } = useDiffSummary( + selectedAttempt?.id ?? null + ); useEffect(() => { if (diffs.length > 0 && loading) { @@ -37,36 +42,8 @@ function DiffTab() { if (initial.size > 0) setCollapsedIds(initial); }, [diffs, collapsedIds.size]); - const { totals, ids } = useMemo(() => { - const ids = diffs.map((d, i) => d.newPath || d.oldPath || String(i)); - const totals = diffs.reduce( - (acc, d) => { - try { - const oldName = d.oldPath || d.newPath || 'old'; - const newName = d.newPath || d.oldPath || 'new'; - const oldContent = d.oldContent || ''; - const newContent = d.newContent || ''; - const oldLang = getHighLightLanguageFromPath(oldName) || 'plaintext'; - const newLang = getHighLightLanguageFromPath(newName) || 'plaintext'; - const file = generateDiffFile( - oldName, - oldContent, - newName, - newContent, - oldLang, - newLang - ); - file.initRaw(); - acc.added += file.additionLength ?? 0; - acc.deleted += file.deletionLength ?? 0; - } catch (e) { - console.error('Failed to compute totals for diff', e); - } - return acc; - }, - { added: 0, deleted: 0 } - ); - return { totals, ids }; + const ids = useMemo(() => { + return diffs.map((d, i) => d.newPath || d.oldPath || String(i)); }, [diffs]); const toggle = useCallback((id: string) => { @@ -108,12 +85,12 @@ function DiffTab() { aria-live="polite" style={{ color: 'hsl(var(--muted-foreground) / 0.7)' }} > - {diffs.length} file{diffs.length === 1 ? '' : 's'} changed,{' '} + {fileCount} file{fileCount === 1 ? '' : 's'} changed,{' '} - +{totals.added} + +{added} {' '} - -{totals.deleted} + -{deleted} + )} +
+ ) : ( +

No description provided

+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/tasks/TaskDetailsHeader.tsx b/frontend/src/components/tasks/TaskDetailsHeader.tsx index d12517fb..6b66d993 100644 --- a/frontend/src/components/tasks/TaskDetailsHeader.tsx +++ b/frontend/src/components/tasks/TaskDetailsHeader.tsx @@ -1,25 +1,19 @@ -import { memo, useContext, useState } from 'react'; -import { - ChevronDown, - ChevronUp, - Edit, - Trash2, - X, - Maximize2, - Minimize2, -} from 'lucide-react'; +import { memo } from 'react'; +import { Edit, Trash2, X, Maximize2, Minimize2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Chip } from '@/components/ui/chip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; -import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types'; -import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts'; +import type { TaskWithAttemptStatus } from 'shared/types'; +import { TaskTitleDescription } from './TaskDetails/TaskTitleDescription'; +import { Card } from '../ui/card'; +import { statusBoardColors, statusLabels } from '@/utils/status-labels'; interface TaskDetailsHeaderProps { + task: TaskWithAttemptStatus; onClose: () => void; onEditTask?: (task: TaskWithAttemptStatus) => void; onDeleteTask?: (taskId: string) => void; @@ -28,32 +22,10 @@ interface TaskDetailsHeaderProps { setFullScreen?: (isFullScreen: boolean) => void; } -const statusLabels: Record = { - todo: 'To Do', - inprogress: 'In Progress', - inreview: 'In Review', - done: 'Done', - cancelled: 'Cancelled', -}; - -const getTaskStatusDotColor = (status: TaskStatus): string => { - switch (status) { - case 'todo': - return 'bg-gray-400'; - case 'inprogress': - return 'bg-blue-500'; - case 'inreview': - return 'bg-yellow-500'; - case 'done': - return 'bg-green-500'; - case 'cancelled': - return 'bg-red-500'; - default: - return 'bg-gray-400'; - } -}; +// backgroundColor: `hsl(var(${statusBoardColors[task.status]}) / 0.03)`, function TaskDetailsHeader({ + task, onClose, onEditTask, onDeleteTask, @@ -61,160 +33,112 @@ function TaskDetailsHeader({ isFullScreen, setFullScreen, }: TaskDetailsHeaderProps) { - const { task } = useContext(TaskDetailsContext); - const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); - return (
- {/* Title and Task Actions */} -
- {/* Top row: title and action icons */} -
-
-
-

- {task.title} - - {statusLabels[task.status]} - -

-
- {setFullScreen && ( - - - - - - -

- {isFullScreen - ? 'Collapse to sidebar' - : 'Expand to fullscreen'} -

-
-
-
- )} -
- {onEditTask && ( - - - - - - -

Edit task

-
-
-
- )} - {onDeleteTask && ( - - - - - - -

Delete task

-
-
-
- )} - {!hideCloseButton && ( - - - - - - -

Close panel

-
-
-
- )} -
-
+ +
+
+

{statusLabels[task.status]}

- - {/* Description + Status (sidebar view only) lives below icons */} - {!isFullScreen && ( -
-
-
- {task.description ? ( -
-

150 - ? 'line-clamp-3' - : '' - }`} - > - {task.description} -

- {task.description.length > 150 && ( - +
+ {setFullScreen && ( + + + +
- ) : ( -

No description provided

- )} -
-
-
- )} -
+ + + +

+ {isFullScreen + ? 'Collapse to sidebar' + : 'Expand to fullscreen'} +

+
+ + + )} + {onEditTask && ( + + + + + + +

Edit task

+
+
+
+ )} + {onDeleteTask && ( + + + + + + +

Delete task

+
+
+
+ )} + {!hideCloseButton && ( + + + + + + +

Close panel

+
+
+
+ )} +
+
+ + {/* Title and Task Actions */} + {!isFullScreen && ( +
+ +
+ )}
); } diff --git a/frontend/src/components/tasks/TaskDetailsPanel.tsx b/frontend/src/components/tasks/TaskDetailsPanel.tsx index 85ad2198..37563c0e 100644 --- a/frontend/src/components/tasks/TaskDetailsPanel.tsx +++ b/frontend/src/components/tasks/TaskDetailsPanel.tsx @@ -2,9 +2,12 @@ import { useEffect, useState } from 'react'; import TaskDetailsHeader from './TaskDetailsHeader'; import { TaskFollowUpSection } from './TaskFollowUpSection'; import { EditorSelectionDialog } from './EditorSelectionDialog'; +import { TaskTitleDescription } from './TaskDetails/TaskTitleDescription'; +import type { TaskAttempt } from 'shared/types'; import { getBackdropClasses, getTaskPanelClasses, + getTaskPanelInnerClasses, } from '@/lib/responsive-config'; import type { TaskWithAttemptStatus } from 'shared/types'; import type { TabType } from '@/types/tabs'; @@ -12,15 +15,13 @@ 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'; +import { AttemptHeaderCard } from './AttemptHeaderCard'; +import { inIframe } from '@/vscode/bridge'; interface TaskDetailsPanelProps { task: TaskWithAttemptStatus | null; @@ -38,6 +39,9 @@ interface TaskDetailsPanelProps { forceCreateAttempt?: boolean; onLeaveForceCreateAttempt?: () => void; onNewAttempt?: () => void; + selectedAttempt: TaskAttempt | null; + attempts: TaskAttempt[]; + setSelectedAttempt: (attempt: TaskAttempt | null) => void; } export function TaskDetailsPanel({ @@ -54,16 +58,27 @@ export function TaskDetailsPanel({ setFullScreen, forceCreateAttempt, onLeaveForceCreateAttempt, + selectedAttempt, + attempts, + setSelectedAttempt, }: TaskDetailsPanelProps) { + // selectedAttempt now comes from AttemptContext for child components const [showEditorDialog, setShowEditorDialog] = useState(false); - const [showCreatePRDialog, setShowCreatePRDialog] = useState(false); - const [creatingPR, setCreatingPR] = useState(false); - const [, setPrError] = useState(null); - const [branches, setBranches] = useState([]); + + // Attempt number, find the current attempt number + const attemptNumber = + attempts.length - + attempts.findIndex((attempt) => attempt.id === selectedAttempt?.id); // Tab and collapsible state const [activeTab, setActiveTab] = useState('logs'); + // Handler for jumping to diff tab in full screen + const jumpToDiffFullScreen = () => { + setFullScreen?.(true); + setActiveTab('diffs'); + }; + // Reset to logs tab when task changes useEffect(() => { if (task?.id) { @@ -71,18 +86,8 @@ 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]); + // Get selected attempt info for props + // (now received as props instead of hook) // Handle ESC key locally to prevent global navigation useEffect(() => { @@ -103,31 +108,26 @@ export function TaskDetailsPanel({ return ( <> {!task ? null : ( - - - - {/* Backdrop - only on smaller screens (overlay mode) */} - {!hideBackdrop && ( -
- )} - - {/* Panel */} + + + {/* Backdrop - only on smaller screens (overlay mode) */} + {!hideBackdrop && (
-
+ className={getBackdropClasses(isFullScreen || false)} + onClick={onClose} + /> + )} + + {/* Panel */} +
+
+ {!inIframe() && ( + )} - {isFullScreen ? ( -
- {/* Sidebar */} - - - {/* Main content */} -
- - -
- {activeTab === 'diffs' ? ( - - ) : activeTab === 'processes' ? ( - - ) : ( - - )} -
- - -
-
- ) : ( - <> -
- + {isFullScreen ? ( +
+ {/* Sidebar */} + + + {/* Main content */} +
- {/* Tab Content */}
{activeTab === 'diffs' ? ( - + ) : activeTab === 'processes' ? ( - + ) : ( - + )}
- - - )} -
+ + +
+ ) : ( + <> + {attempts.length === 0 ? ( + + ) : ( + <> + { + // // TODO: Implement create new attempt + // console.log('Create new attempt'); + // }} + onJumpToDiffFullScreen={jumpToDiffFullScreen} + /> + + + + + + )} + + )}
+
- setShowEditorDialog(false)} - /> + setShowEditorDialog(false)} + selectedAttempt={selectedAttempt} + /> - - - - {/* PR Dialog mounted within provider so it has task context */} - - + + + )} ); diff --git a/frontend/src/components/tasks/TaskDetailsToolbar.tsx b/frontend/src/components/tasks/TaskDetailsToolbar.tsx index c209c19a..60fd82db 100644 --- a/frontend/src/components/tasks/TaskDetailsToolbar.tsx +++ b/frontend/src/components/tasks/TaskDetailsToolbar.tsx @@ -1,29 +1,21 @@ -import { - useCallback, - useContext, - useEffect, - useMemo, - useReducer, - useState, -} from 'react'; -import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import { Play } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { attemptsApi, projectsApi } from '@/lib/api'; -import type { GitBranch, ProfileVariantLabel } from 'shared/types'; -import type { TaskAttempt } from 'shared/types'; +import { projectsApi } from '@/lib/api'; +import type { + GitBranch, + ProfileVariantLabel, + TaskAttempt, + TaskWithAttemptStatus, +} from 'shared/types'; + +import { useAttemptExecution } from '@/hooks'; +import { useTaskStopping } from '@/stores/useTaskDetailsUiStore'; -import { - TaskAttemptDataContext, - TaskAttemptLoadingContext, - TaskAttemptStoppingContext, - TaskDetailsContext, - TaskSelectedAttemptContext, -} from '@/components/context/taskDetailsContext.ts'; -import CreatePRDialog from '@/components/tasks/Toolbar/CreatePRDialog.tsx'; import CreateAttempt from '@/components/tasks/Toolbar/CreateAttempt.tsx'; import CurrentAttempt from '@/components/tasks/Toolbar/CurrentAttempt.tsx'; import { useUserSystem } from '@/components/config-provider'; +import { Card } from '../ui/card'; // UI State Management type UiAction = @@ -71,36 +63,39 @@ function uiReducer(state: UiState, action: UiAction): UiState { } function TaskDetailsToolbar({ + task, + projectId, + projectHasDevScript, forceCreateAttempt, onLeaveForceCreateAttempt, + attempts, + selectedAttempt, + setSelectedAttempt, }: { + task: TaskWithAttemptStatus; + projectId: string; + projectHasDevScript?: boolean; forceCreateAttempt?: boolean; onLeaveForceCreateAttempt?: () => void; + attempts: TaskAttempt[]; + selectedAttempt: TaskAttempt | null; + setSelectedAttempt: (attempt: TaskAttempt | null) => void; }) { - const { task, projectId } = useContext(TaskDetailsContext); - const { setLoading } = useContext(TaskAttemptLoadingContext); - const { selectedAttempt, setSelectedAttempt } = useContext( - TaskSelectedAttemptContext - ); - - const { isStopping } = useContext(TaskAttemptStoppingContext); - const location = useLocation(); - const { setAttemptData, isAttemptRunning } = useContext( - TaskAttemptDataContext - ); + // Use props instead of context + const taskAttempts = attempts; + // const { setLoading } = useTaskLoading(task.id); + const { isStopping } = useTaskStopping(task.id); + const { isAttemptRunning } = useAttemptExecution(selectedAttempt?.id); // UI state using reducer const [ui, dispatch] = useReducer(uiReducer, initialUi); // Data state - const [taskAttempts, setTaskAttempts] = useState([]); const [branches, setBranches] = useState([]); const [selectedBranch, setSelectedBranch] = useState(null); const [selectedProfile, setSelectedProfile] = useState(null); - - const navigate = useNavigate(); - const { attemptId: urlAttemptId } = useParams<{ attemptId?: string }>(); + // const { attemptId: urlAttemptId } = useParams<{ attemptId?: string }>(); const { system, profiles } = useUserSystem(); // Memoize latest attempt calculation @@ -153,131 +148,18 @@ function TaskDetailsToolbar({ } }, [system.config?.profile]); - const fetchTaskAttempts = useCallback(async () => { - if (!task) return; + // Simplified - hooks handle data fetching and navigation + // const fetchTaskAttempts = useCallback(() => { + // // The useSelectedAttempt hook handles all this logic now + // }, []); - try { - setLoading(true); - const result = await attemptsApi.getAll(task.id); - - setTaskAttempts((prev) => { - if (JSON.stringify(prev) === JSON.stringify(result)) return prev; - return result || prev; - }); - - if (result.length > 0) { - // Check if we have a new latest attempt (newly created) - const currentLatest = - taskAttempts.length > 0 - ? taskAttempts.reduce((latest, current) => - new Date(current.created_at) > new Date(latest.created_at) - ? current - : latest - ) - : null; - - const newLatest = result.reduce((latest, current) => - new Date(current.created_at) > new Date(latest.created_at) - ? current - : latest - ); - - // If we have a new attempt that wasn't there before, navigate to it immediately - const hasNewAttempt = - newLatest && (!currentLatest || newLatest.id !== currentLatest.id); - - if (hasNewAttempt) { - // Always navigate to newly created attempts - handleAttemptSelect(newLatest); - return; - } - - // Otherwise, follow existing logic for URL-based attempt selection - const urlParams = new URLSearchParams(location.search); - const queryAttemptParam = urlParams.get('attempt'); - const attemptParam = urlAttemptId || queryAttemptParam; - - let selectedAttemptToUse: TaskAttempt; - - if (attemptParam) { - const specificAttempt = result.find( - (attempt) => attempt.id === attemptParam - ); - if (specificAttempt) { - selectedAttemptToUse = specificAttempt; - } else { - selectedAttemptToUse = newLatest; - } - } else { - selectedAttemptToUse = newLatest; - } - - setSelectedAttempt((prev) => { - if (JSON.stringify(prev) === JSON.stringify(selectedAttemptToUse)) - return prev; - - // Only navigate if we're not already on the correct attempt URL - if ( - selectedAttemptToUse && - task && - (!urlAttemptId || urlAttemptId !== selectedAttemptToUse.id) - ) { - const isFullScreen = location.pathname.endsWith('/full'); - const targetUrl = isFullScreen - ? `/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttemptToUse.id}/full` - : `/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttemptToUse.id}`; - navigate(targetUrl, { replace: true }); - } - - return selectedAttemptToUse; - }); - } else { - setSelectedAttempt(null); - setAttemptData({ - processes: [], - runningProcessDetails: {}, - }); - } - } catch (error) { - // we already logged error - } finally { - setLoading(false); - } - }, [ - task, - location.search, - urlAttemptId, - navigate, - projectId, - setLoading, - setSelectedAttempt, - setAttemptData, - ]); - - useEffect(() => { - fetchTaskAttempts(); - }, [fetchTaskAttempts]); + // Remove fetchTaskAttempts - hooks handle this now // Handle entering create attempt mode const handleEnterCreateAttemptMode = useCallback(() => { dispatch({ type: 'ENTER_CREATE_MODE' }); }, []); - // Handle attempt selection with URL navigation - const handleAttemptSelect = useCallback( - (attempt: TaskAttempt | null) => { - setSelectedAttempt(attempt); - if (attempt && task) { - const isFullScreen = location.pathname.endsWith('/full'); - const targetUrl = isFullScreen - ? `/projects/${projectId}/tasks/${task.id}/attempts/${attempt.id}/full` - : `/projects/${projectId}/tasks/${task.id}/attempts/${attempt.id}`; - navigate(targetUrl, { replace: true }); - } - }, - [navigate, projectId, task, setSelectedAttempt, location.pathname] - ); - // Stub handlers for backward compatibility with CreateAttempt const setCreateAttemptBranch = useCallback( (branch: string | null | ((prev: string | null) => string | null)) => { @@ -314,37 +196,19 @@ function TaskDetailsToolbar({ [ui.error] ); - const setShowCreatePRDialog = useCallback( - (value: boolean | ((prev: boolean) => boolean)) => { - const boolValue = - typeof value === 'function' ? value(ui.showCreatePRDialog) : value; - dispatch({ type: boolValue ? 'OPEN_CREATE_PR' : 'CLOSE_CREATE_PR' }); - }, - [ui.showCreatePRDialog] - ); - - const setCreatingPR = useCallback( - (value: boolean | ((prev: boolean) => boolean)) => { - const boolValue = - typeof value === 'function' ? value(ui.creatingPR) : value; - dispatch({ type: boolValue ? 'CREATE_PR_START' : 'CREATE_PR_DONE' }); - }, - [ui.creatingPR] - ); - return ( <>
{/* Error Display */} {ui.error && ( -
+
{ui.error}
)} {isInCreateAttemptMode ? ( ) : ( -
- {/* Current Attempt Info */} -
- {selectedAttempt ? ( - - ) : ( -
-
- No attempts yet -
-
- Start your first attempt to begin working on this task +
+ + Actions + +
+ {/* Current Attempt Info */} +
+ {selectedAttempt ? ( + + ) : ( +
+
+ No attempts yet +
+
+ Start your first attempt to begin working on this task +
+ )} +
+ + {/* Special Actions: show only in sidebar (non-fullscreen) */} + {!selectedAttempt && !isAttemptRunning && !isStopping && ( +
+
)}
- - {/* Special Actions: show only in sidebar (non-fullscreen) */} - {!selectedAttempt && !isAttemptRunning && !isStopping && ( -
- -
- )}
)}
- - ); } diff --git a/frontend/src/components/tasks/TaskFollowUpSection.tsx b/frontend/src/components/tasks/TaskFollowUpSection.tsx index f9316398..50067fdc 100644 --- a/frontend/src/components/tasks/TaskFollowUpSection.tsx +++ b/frontend/src/components/tasks/TaskFollowUpSection.tsx @@ -1,23 +1,19 @@ -import { AlertCircle, Send, ChevronDown, ImageIcon } from 'lucide-react'; +import { + AlertCircle, + Send, + ChevronDown, + ImageIcon, + StopCircle, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ImageUploadSection } from '@/components/ui/ImageUploadSection'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { FileSearchTextarea } from '@/components/ui/file-search-textarea'; -import { - useContext, - useEffect, - useMemo, - useState, - useRef, - useCallback, -} from 'react'; +import { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { attemptsApi, imagesApi } from '@/lib/api.ts'; -import type { ImageResponse } from 'shared/types'; -import { - TaskAttemptDataContext, - TaskDetailsContext, - TaskSelectedAttemptContext, -} from '@/components/context/taskDetailsContext.ts'; +import type { ImageResponse, TaskWithAttemptStatus } from 'shared/types'; +import { useBranchStatus } from '@/hooks'; +import { useAttemptExecution } from '@/hooks/useAttemptExecution'; import { Loader } from '@/components/ui/loader'; import { useUserSystem } from '@/components/config-provider'; import { @@ -29,18 +25,63 @@ import { import { cn } from '@/lib/utils'; import { useVariantCyclingShortcut } from '@/lib/keyboard-shortcuts'; -export function TaskFollowUpSection() { - const { task, projectId } = useContext(TaskDetailsContext); - const { selectedAttempt } = useContext(TaskSelectedAttemptContext); +interface TaskFollowUpSectionProps { + task: TaskWithAttemptStatus; + projectId: string; + selectedAttemptId?: string; + selectedAttemptProfile?: string; +} + +export function TaskFollowUpSection({ + task, + projectId, + selectedAttemptId, + selectedAttemptProfile, +}: TaskFollowUpSectionProps) { const { attemptData, - fetchAttemptData, isAttemptRunning, - defaultFollowUpVariant, - branchStatus, - } = useContext(TaskAttemptDataContext); + stopExecution, + isStopping, + processes, + } = useAttemptExecution(selectedAttemptId, task.id); + const { data: branchStatus } = useBranchStatus(selectedAttemptId); const { profiles } = useUserSystem(); + // Inline defaultFollowUpVariant logic + const defaultFollowUpVariant = useMemo(() => { + if (!processes.length) return null; + + // Find most recent coding agent process with variant + const latestProfile = processes + .filter((p) => p.run_reason === 'codingagent') + .reverse() + .map((process) => { + if ( + process.executor_action?.typ.type === 'CodingAgentInitialRequest' || + process.executor_action?.typ.type === 'CodingAgentFollowUpRequest' + ) { + return process.executor_action?.typ.profile_variant_label; + } + return undefined; + }) + .find(Boolean); + + if (latestProfile?.variant) { + return latestProfile.variant; + } else if (latestProfile) { + return null; + } else if (selectedAttemptProfile && profiles) { + // No processes yet, check if profile has default variant + const profile = profiles.find((p) => p.label === selectedAttemptProfile); + if (profile?.variants && profile.variants.length > 0) { + return profile.variants[0].label; + } + } + + return null; + }, [processes, selectedAttemptProfile, profiles]); + const [followUpMessage, setFollowUpMessage] = useState(''); const [isSendingFollowUp, setIsSendingFollowUp] = useState(false); const [followUpError, setFollowUpError] = useState(null); @@ -55,14 +96,13 @@ export function TaskFollowUpSection() { [] ); - // Get the profile from the selected attempt - const selectedProfile = selectedAttempt?.profile || null; + // Get the profile from the attempt data + const selectedProfile = selectedAttemptProfile; const canSendFollowUp = useMemo(() => { if ( - !selectedAttempt || + !selectedAttemptId || attemptData.processes.length === 0 || - isAttemptRunning || isSendingFollowUp ) { return false; @@ -80,9 +120,8 @@ export function TaskFollowUpSection() { return true; }, [ - selectedAttempt, + selectedAttemptId, attemptData.processes, - isAttemptRunning, isSendingFollowUp, branchStatus?.merges, ]); @@ -119,7 +158,7 @@ export function TaskFollowUpSection() { }); const onSendFollowUp = async () => { - if (!task || !selectedAttempt || !followUpMessage.trim()) return; + if (!task || !selectedAttemptId || !followUpMessage.trim()) return; try { setIsSendingFollowUp(true); @@ -132,7 +171,7 @@ export function TaskFollowUpSection() { ? images.map((img) => img.id) : null; - await attemptsApi.followUp(selectedAttempt.id, { + await attemptsApi.followUp(selectedAttemptId, { prompt: followUpMessage.trim(), variant: selectedVariant, image_ids: imageIds, @@ -142,7 +181,7 @@ export function TaskFollowUpSection() { setImages([]); setNewlyUploadedImageIds([]); setShowImageUpload(false); - fetchAttemptData(selectedAttempt.id); + // No need to manually refetch - React Query will handle this } catch (error: unknown) { // @ts-expect-error it is type ApiError setFollowUpError(`Failed to start follow-up execution: ${error.message}`); @@ -152,8 +191,8 @@ export function TaskFollowUpSection() { }; return ( - selectedAttempt && ( -
+ selectedAttemptId && ( +
{followUpError && ( @@ -176,131 +215,158 @@ export function TaskFollowUpSection() { />
)} -
- { - setFollowUpMessage(value); - if (followUpError) setFollowUpError(null); - }} - onKeyDown={(e) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - e.preventDefault(); - if ( - canSendFollowUp && - followUpMessage.trim() && - !isSendingFollowUp - ) { - onSendFollowUp(); +
+
+ { + setFollowUpMessage(value); + if (followUpError) setFollowUpError(null); + }} + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + if ( + canSendFollowUp && + followUpMessage.trim() && + !isSendingFollowUp + ) { + onSendFollowUp(); + } } - } - }} - className="flex-1 min-h-[40px] resize-none" - disabled={!canSendFollowUp} - projectId={projectId} - rows={1} - maxRows={6} - /> - - {/* Image button */} - +
+
+
+ {/* Image button */} + - {/* Variant selector */} - {(() => { - const hasVariants = - currentProfile?.variants && - currentProfile.variants.length > 0; + {/* Variant selector */} + {(() => { + const hasVariants = + currentProfile?.variants && + currentProfile.variants.length > 0; - if (hasVariants) { - return ( - - + if (hasVariants) { + return ( + + + + + + setSelectedVariant(null)} + className={!selectedVariant ? 'bg-accent' : ''} + > + Default + + {currentProfile.variants.map((variant) => ( + + setSelectedVariant(variant.label) + } + className={ + selectedVariant === variant.label + ? 'bg-accent' + : '' + } + > + {variant.label} + + ))} + + + ); + } else if (currentProfile) { + // Show disabled button when profile exists but has no variants + return ( - - - setSelectedVariant(null)} - className={!selectedVariant ? 'bg-accent' : ''} - > - Default - - {currentProfile.variants.map((variant) => ( - setSelectedVariant(variant.label)} - className={ - selectedVariant === variant.label - ? 'bg-accent' - : '' - } - > - {variant.label} - - ))} - - - ); - } else if (currentProfile) { - // Show disabled button when profile exists but has no variants - return ( - - ); - } - return null; - })()} - -
+ {isAttemptRunning ? ( + ) : ( - <> - - Send - + )} - +
diff --git a/frontend/src/components/tasks/TaskFormDialogContainer.tsx b/frontend/src/components/tasks/TaskFormDialogContainer.tsx new file mode 100644 index 00000000..4b0e084a --- /dev/null +++ b/frontend/src/components/tasks/TaskFormDialogContainer.tsx @@ -0,0 +1,137 @@ +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { TaskFormDialog } from './TaskFormDialog'; +import { useTaskDialog } from '@/contexts/task-dialog-context'; +import { useProject } from '@/contexts/project-context'; +import { tasksApi } from '@/lib/api'; +import type { TaskStatus, CreateTask } from 'shared/types'; + +/** + * Container component that bridges the TaskDialogContext with TaskFormDialog + * Handles API calls while keeping the context UI-only as recommended by Oracle + */ +export function TaskFormDialogContainer() { + const { dialogState, close, handleSuccess } = useTaskDialog(); + const { projectId } = useProject(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + // React Query mutations + const createTaskMutation = useMutation({ + mutationFn: (data: CreateTask) => tasksApi.create(data), + onSuccess: (createdTask) => { + // Invalidate and refetch tasks list + queryClient.invalidateQueries({ queryKey: ['tasks', projectId] }); + + // Navigate to the new task + navigate(`/projects/${projectId}/tasks/${createdTask.id}`, { + replace: true, + }); + + handleSuccess(createdTask); + }, + onError: (err) => { + console.error('Failed to create task:', err); + }, + }); + + const createAndStartTaskMutation = useMutation({ + mutationFn: (data: CreateTask) => tasksApi.createAndStart(data), + onSuccess: (result) => { + // Invalidate and refetch tasks list + queryClient.invalidateQueries({ queryKey: ['tasks', projectId] }); + + // Navigate to the new task + navigate(`/projects/${projectId}/tasks/${result.id}`, { + replace: true, + }); + + handleSuccess(result); + }, + onError: (err) => { + console.error('Failed to create and start task:', err); + }, + }); + + const updateTaskMutation = useMutation({ + mutationFn: ({ taskId, data }: { taskId: string; data: any }) => + tasksApi.update(taskId, data), + onSuccess: (updatedTask) => { + // Invalidate and refetch tasks list and individual task + queryClient.invalidateQueries({ queryKey: ['tasks', projectId] }); + queryClient.invalidateQueries({ queryKey: ['task', updatedTask.id] }); + + handleSuccess(updatedTask); + }, + onError: (err) => { + console.error('Failed to update task:', err); + }, + }); + + const handleCreateTask = useCallback( + async (title: string, description: string, imageIds?: string[]) => { + if (!projectId) return; + + createTaskMutation.mutate({ + project_id: projectId, + title, + description: description || null, + parent_task_attempt: null, + image_ids: imageIds || null, + }); + }, + [projectId, createTaskMutation] + ); + + const handleCreateAndStartTask = useCallback( + async (title: string, description: string, imageIds?: string[]) => { + if (!projectId) return; + + createAndStartTaskMutation.mutate({ + project_id: projectId, + title, + description: description || null, + parent_task_attempt: null, + image_ids: imageIds || null, + }); + }, + [projectId, createAndStartTaskMutation] + ); + + const handleUpdateTask = useCallback( + async ( + title: string, + description: string, + status: TaskStatus, + imageIds?: string[] + ) => { + if (!dialogState.task) return; + + updateTaskMutation.mutate({ + taskId: dialogState.task.id, + data: { + title, + description: description || null, + status, + parent_task_attempt: null, + image_ids: imageIds || null, + }, + }); + }, + [dialogState.task, updateTaskMutation] + ); + + return ( + !open && close()} + task={dialogState.task} + projectId={projectId || undefined} + initialTemplate={dialogState.initialTemplate} + onCreateTask={handleCreateTask} + onCreateAndStartTask={handleCreateAndStartTask} + onUpdateTask={handleUpdateTask} + /> + ); +} diff --git a/frontend/src/components/tasks/TaskKanbanBoard.tsx b/frontend/src/components/tasks/TaskKanbanBoard.tsx index 525e3d09..76ed061f 100644 --- a/frontend/src/components/tasks/TaskKanbanBoard.tsx +++ b/frontend/src/components/tasks/TaskKanbanBoard.tsx @@ -13,6 +13,7 @@ import { useKeyboardShortcuts, useKanbanKeyboardNavigation, } from '@/lib/keyboard-shortcuts.ts'; +import { statusBoardColors, statusLabels } from '@/utils/status-labels'; type Task = TaskWithAttemptStatus; @@ -34,22 +35,6 @@ const allTaskStatuses: TaskStatus[] = [ 'cancelled', ]; -const statusLabels: Record = { - todo: 'To Do', - inprogress: 'In Progress', - inreview: 'In Review', - done: 'Done', - cancelled: 'Cancelled', -}; - -const statusBoardColors: Record = { - todo: 'hsl(var(--neutral))', - inprogress: 'hsl(var(--info))', - inreview: 'hsl(var(--warning))', - done: 'hsl(var(--success))', - cancelled: 'hsl(var(--destructive))', -}; - function TaskKanbanBoard({ tasks, searchQuery = '', diff --git a/frontend/src/components/tasks/TodoPanel.tsx b/frontend/src/components/tasks/TodoPanel.tsx index b460a828..310eb72b 100644 --- a/frontend/src/components/tasks/TodoPanel.tsx +++ b/frontend/src/components/tasks/TodoPanel.tsx @@ -1,9 +1,11 @@ -import { useContext, useMemo } from 'react'; +import { 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 { useAttemptExecution } from '@/hooks'; import { shouldShowInLogs } from '@/constants/processes'; +import type { TaskAttempt } from 'shared/types'; +import { Card } from '../ui/card'; function getStatusIcon(status?: string) { const s = (status || '').toLowerCase(); @@ -14,8 +16,12 @@ function getStatusIcon(status?: string) { return ; } -export function TodoPanel() { - const { attemptData } = useContext(TaskAttemptDataContext); +interface TodoPanelProps { + selectedAttempt: TaskAttempt | null; +} + +export function TodoPanel({ selectedAttempt }: TodoPanelProps) { + const { attemptData } = useAttemptExecution(selectedAttempt?.id); const filteredProcesses = useMemo( () => @@ -32,9 +38,11 @@ export function TodoPanel() { if (!todos || todos.length === 0) return null; return ( -
-
-

Task Breakdown

+
+ + Todos + +
    {todos.map((todo, index) => (
  • void; setIsInCreateAttemptMode: Dispatch>; setCreateAttemptBranch: Dispatch>; setSelectedProfile: Dispatch>; availableProfiles: ProfileConfig[] | null; + selectedAttempt: TaskAttempt | null; }; function CreateAttempt({ + task, branches, taskAttempts, createAttemptBranch, selectedProfile, selectedBranch, - fetchTaskAttempts, setIsInCreateAttemptMode, setCreateAttemptBranch, setSelectedProfile, availableProfiles, + selectedAttempt, }: Props) { - const { task } = useContext(TaskDetailsContext); - const { isAttemptRunning } = useContext(TaskAttemptDataContext); + const { isAttemptRunning } = useAttemptExecution(selectedAttempt?.id); + const { createAttempt, isCreating } = useAttemptCreation(task.id); const [showCreateAttemptConfirmation, setShowCreateAttemptConfirmation] = useState(false); @@ -74,14 +74,12 @@ function CreateAttempt({ throw new Error('Base branch is required to create an attempt'); } - await attemptsApi.create({ - task_id: task.id, - profile_variant_label: profile, - base_branch: effectiveBaseBranch, + await createAttempt({ + profile, + baseBranch: effectiveBaseBranch, }); - fetchTaskAttempts(); }, - [task.id, selectedProfile, selectedBranch, fetchTaskAttempts] + [createAttempt, selectedBranch] ); // Handler for Enter key or Start button @@ -145,10 +143,12 @@ function CreateAttempt({ }; return ( -
    -
    +
    + + Create Attempt + +
    -

    Create Attempt

    {taskAttempts.length > 0 && (
    @@ -370,9 +373,10 @@ function CreateAttempt({ diff --git a/frontend/src/components/tasks/Toolbar/CreatePRDialog.tsx b/frontend/src/components/tasks/Toolbar/CreatePRDialog.tsx index a7507883..cf1662c5 100644 --- a/frontend/src/components/tasks/Toolbar/CreatePRDialog.tsx +++ b/frontend/src/components/tasks/Toolbar/CreatePRDialog.tsx @@ -17,67 +17,43 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { useCallback, useContext, useEffect, useState } from 'react'; -import { - TaskAttemptDataContext, - TaskDetailsContext, - TaskSelectedAttemptContext, -} from '@/components/context/taskDetailsContext.ts'; +import { useCallback, useEffect, useState } from 'react'; import { attemptsApi } from '@/lib/api.ts'; import { ProvidePatDialog } from '@/components/ProvidePatDialog'; import { GitHubLoginDialog } from '@/components/GitHubLoginDialog'; -import { GitBranch, GitHubServiceError } from 'shared/types'; +import { GitHubServiceError } from 'shared/types'; +import { useCreatePRDialog } from '@/contexts/create-pr-dialog-context'; +import { useProjectBranches } from '@/hooks'; -type Props = { - showCreatePRDialog: boolean; - setShowCreatePRDialog: (show: boolean) => void; - creatingPR: boolean; - setCreatingPR: (creating: boolean) => void; - setError: (error: string | null) => void; - branches: GitBranch[]; -}; - -function CreatePrDialog({ - showCreatePRDialog, - setCreatingPR, - setShowCreatePRDialog, - creatingPR, - setError, - branches, -}: Props) { - const { projectId, task } = useContext(TaskDetailsContext); - const { selectedAttempt } = useContext(TaskSelectedAttemptContext); - const { fetchAttemptData } = useContext(TaskAttemptDataContext); +function CreatePrDialog() { + const { isOpen, data, closeCreatePRDialog } = useCreatePRDialog(); const [prTitle, setPrTitle] = useState(''); const [prBody, setPrBody] = useState(''); - const [prBaseBranch, setPrBaseBranch] = useState( - selectedAttempt?.base_branch || 'main' - ); + const [prBaseBranch, setPrBaseBranch] = useState('main'); const [showPatDialog, setShowPatDialog] = useState(false); const [patDialogError, setPatDialogError] = useState(null); const [showGitHubLoginDialog, setShowGitHubLoginDialog] = useState(false); + const [creatingPR, setCreatingPR] = useState(false); + const [error, setError] = useState(null); + + // Fetch branches when dialog opens + const { data: branches = [], isLoading: branchesLoading } = + useProjectBranches(isOpen ? data?.projectId : undefined); useEffect(() => { - if (showCreatePRDialog) { - setPrTitle(`${task.title} (vibe-kanban)`); - setPrBody(task.description || ''); + if (isOpen && data) { + setPrTitle(`${data.task.title} (vibe-kanban)`); + setPrBody(data.task.description || ''); + setError(null); // Reset error when opening } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [showCreatePRDialog]); - - // Update PR base branch when selected attempt changes - useEffect(() => { - if (selectedAttempt?.base_branch) { - setPrBaseBranch(selectedAttempt.base_branch); - } - }, [selectedAttempt?.base_branch]); + }, [isOpen, data]); const handleConfirmCreatePR = useCallback(async () => { - if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return; + if (!data?.projectId || !data?.attempt.id) return; setCreatingPR(true); - const result = await attemptsApi.createPR(selectedAttempt.id, { + const result = await attemptsApi.createPR(data.attempt.id, { title: prTitle, body: prBody || null, base_branch: prBaseBranch || null, @@ -86,15 +62,14 @@ function CreatePrDialog({ if (result.success) { setError(null); // Clear any previous errors on success window.open(result.data, '_blank'); - // Reset form + // Reset form and close dialog setPrTitle(''); setPrBody(''); - setPrBaseBranch(selectedAttempt?.base_branch || 'main'); - // Refresh branch status to show the new PR - fetchAttemptData(selectedAttempt.id); + setPrBaseBranch('main'); + closeCreatePRDialog(); } else { if (result.error) { - setShowCreatePRDialog(false); + closeCreatePRDialog(); switch (result.error) { case GitHubServiceError.TOKEN_INVALID: setShowGitHubLoginDialog(true); @@ -116,37 +91,30 @@ function CreatePrDialog({ setError('Failed to create GitHub PR'); } } - setShowCreatePRDialog(false); setCreatingPR(false); }, [ - projectId, - selectedAttempt, + data, prBaseBranch, prBody, prTitle, - fetchAttemptData, - setCreatingPR, - setError, - setShowCreatePRDialog, + closeCreatePRDialog, setPatDialogError, - setShowPatDialog, - setShowGitHubLoginDialog, ]); const handleCancelCreatePR = useCallback(() => { - setShowCreatePRDialog(false); + closeCreatePRDialog(); // Reset form to empty state setPrTitle(''); setPrBody(''); setPrBaseBranch('main'); - }, [setShowCreatePRDialog]); + }, [closeCreatePRDialog]); + + // Don't render if no data + if (!data) return null; return ( <> - handleCancelCreatePR()} - > + handleCancelCreatePR()}> Create GitHub Pull Request @@ -176,9 +144,19 @@ function CreatePrDialog({
    - - + {branches.map((branch) => ( @@ -197,6 +175,11 @@ function CreatePrDialog({
    + {error && ( +
    + {error} +
    + )}
    @@ -815,7 +756,7 @@ function CurrentAttempt({ variant="destructive" onClick={async () => { setShowStopConfirmation(false); - await stopAllExecutions(); + await stopExecution(); }} disabled={isStopping} > diff --git a/frontend/src/components/ui/auto-expanding-textarea.tsx b/frontend/src/components/ui/auto-expanding-textarea.tsx index b7b5cf7c..5efae846 100644 --- a/frontend/src/components/ui/auto-expanding-textarea.tsx +++ b/frontend/src/components/ui/auto-expanding-textarea.tsx @@ -55,7 +55,7 @@ const AutoExpandingTextarea = React.forwardRef< return (