diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8adba24d..cdaa00a5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,9 @@ name: Test on: pull_request: - branches: [ main ] + branches: + - main + - louis/fe-revision workflow_dispatch: concurrency: diff --git a/frontend/package.json b/frontend/package.json index bfe4aced..1de8be47 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,13 @@ "@dnd-kit/modifiers": "^9.0.0", "@git-diff-view/file": "^0.0.30", "@git-diff-view/react": "^0.0.30", + "@lexical/code": "^0.36.2", + "@lexical/link": "^0.36.2", + "@lexical/list": "^0.36.2", + "@lexical/markdown": "^0.36.2", + "@lexical/react": "^0.36.2", + "@lexical/rich-text": "^0.36.2", + "@lexical/utils": "^0.36.2", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.5", @@ -44,6 +51,7 @@ "fancy-ansi": "^0.1.3", "i18next": "^25.5.2", "i18next-browser-languagedetector": "^8.2.0", + "lexical": "^0.36.2", "lucide-react": "^0.539.0", "markdown-to-jsx": "^7.7.13", "react": "^18.2.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 25d94156..7c932b66 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,11 +2,9 @@ import { useEffect } from 'react'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { I18nextProvider } from 'react-i18next'; import i18n from '@/i18n'; -import { Navbar } from '@/components/layout/navbar'; import { Projects } from '@/pages/projects'; import { ProjectTasks } from '@/pages/project-tasks'; -import { useTaskViewManager } from '@/hooks/useTaskViewManager'; -import { usePreviousPath } from '@/hooks/usePreviousPath'; +import { NormalLayout } from '@/components/layout/NormalLayout'; import { AgentSettings, @@ -32,7 +30,6 @@ import { Loader } from '@/components/ui/loader'; import { AppWithStyleOverride } from '@/utils/style-override'; import { WebviewContextMenu } from '@/vscode/ContextMenu'; -import { DevBanner } from '@/components/DevBanner'; import NiceModal from '@ebay/nice-modal-react'; import { OnboardingResult } from './components/dialogs/global/OnboardingDialog'; import { ClickedElementsProvider } from './contexts/ClickedElementsProvider'; @@ -41,12 +38,6 @@ const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); function AppContent() { const { config, updateAndSaveConfig, loading } = useUserSystem(); - const { isFullscreen } = useTaskViewManager(); - - // Track previous path for back navigation - usePreviousPath(); - - const showNavbar = !isFullscreen; useEffect(() => { let cancelled = false; @@ -150,13 +141,10 @@ function AppContent() {
- {/* Custom context menu and VS Code-friendly interactions when embedded in iframe */} - {showNavbar && } - {showNavbar && } -
- + + }> } /> } /> } /> @@ -164,35 +152,26 @@ function AppContent() { path="/projects/:projectId/tasks" element={} /> - } - /> - } - /> - } - /> - } - /> }> } /> } /> } /> } /> - {/* Redirect old MCP route */} } /> - -
+ } + /> + } + /> + +
diff --git a/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx b/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx index 36e4d8bc..63c72434 100644 --- a/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx +++ b/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx @@ -594,6 +594,14 @@ const getToolStatusAppearance = (status: ToolStatus): ToolStatusAppearance => { * Main component * *******************/ +export const DisplayConversationEntryMaxWidth = (props: Props) => { + return ( +
+ +
+ ); +}; + function DisplayConversationEntry({ entry, expansionKey, @@ -794,4 +802,4 @@ function DisplayConversationEntry({ ); } -export default DisplayConversationEntry; +export default DisplayConversationEntryMaxWidth; diff --git a/frontend/src/components/NormalizedConversation/RetryEditorInline.tsx b/frontend/src/components/NormalizedConversation/RetryEditorInline.tsx index 0f887f41..906b9ebe 100644 --- a/frontend/src/components/NormalizedConversation/RetryEditorInline.tsx +++ b/frontend/src/components/NormalizedConversation/RetryEditorInline.tsx @@ -251,7 +251,7 @@ export function RetryEditorInline({ ]); return ( -
+
{initError && ( diff --git a/frontend/src/components/NormalizedConversation/UserMessage.tsx b/frontend/src/components/NormalizedConversation/UserMessage.tsx index 0a713f84..9b39be53 100644 --- a/frontend/src/components/NormalizedConversation/UserMessage.tsx +++ b/frontend/src/components/NormalizedConversation/UserMessage.tsx @@ -67,10 +67,17 @@ const UserMessage = ({ isProcessGreyed(executionProcessId) && !showRetryEditor; + const retryState = executionProcessId + ? retryHook?.getRetryDisabledState(executionProcessId) + : { disabled: true, reason: 'Missing process id' }; + const disabled = !!retryState?.disabled; + const reason = retryState?.reason ?? undefined; + const editTitle = disabled && reason ? reason : 'Edit message'; + return (
-
-
+
+
{showRetryEditor ? ( )}
{executionProcessId && canFork && !showRetryEditor && ( -
- {(() => { - const state = executionProcessId - ? retryHook?.getRetryDisabledState(executionProcessId) - : { disabled: true, reason: 'Missing process id' }; - const disabled = !!state?.disabled; - const reason = state?.reason ?? undefined; - return ( - - ); - })()} +
+
)}
diff --git a/frontend/src/components/dialogs/index.ts b/frontend/src/components/dialogs/index.ts index 0e19da71..3d98a9d9 100644 --- a/frontend/src/components/dialogs/index.ts +++ b/frontend/src/components/dialogs/index.ts @@ -57,6 +57,10 @@ export { type RestoreLogsDialogProps, type RestoreLogsDialogResult, } from './tasks/RestoreLogsDialog'; +export { + ViewProcessesDialog, + type ViewProcessesDialogProps, +} from './tasks/ViewProcessesDialog'; // Settings dialogs export { diff --git a/frontend/src/components/dialogs/tasks/CreateAttemptDialog.tsx b/frontend/src/components/dialogs/tasks/CreateAttemptDialog.tsx new file mode 100644 index 00000000..3136d00b --- /dev/null +++ b/frontend/src/components/dialogs/tasks/CreateAttemptDialog.tsx @@ -0,0 +1,198 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import BranchSelector from '@/components/tasks/BranchSelector'; +import { ExecutorProfileSelector } from '@/components/settings'; +import { useAttemptCreation } from '@/hooks/useAttemptCreation'; +import { useNavigateWithSearch } from '@/hooks'; +import { useProject } from '@/contexts/project-context'; +import { useUserSystem } from '@/components/config-provider'; +import { projectsApi } from '@/lib/api'; +import { paths } from '@/lib/paths'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import type { + GitBranch, + ExecutorProfileId, + TaskAttempt, + BaseCodingAgent, +} from 'shared/types'; + +export interface CreateAttemptDialogProps { + taskId: string; + latestAttempt?: TaskAttempt | null; +} + +export const CreateAttemptDialog = NiceModal.create( + ({ taskId, latestAttempt }) => { + const modal = useModal(); + const navigate = useNavigateWithSearch(); + const { projectId } = useProject(); + const { t } = useTranslation('tasks'); + const { profiles, config } = useUserSystem(); + const { createAttempt, isCreating, error } = useAttemptCreation({ + taskId, + onSuccess: (attempt) => { + if (projectId) { + navigate(paths.attempt(projectId, taskId, attempt.id)); + } + }, + }); + + const [selectedProfile, setSelectedProfile] = + useState(null); + const [selectedBranch, setSelectedBranch] = useState(null); + const [branches, setBranches] = useState([]); + const [isLoadingBranches, setIsLoadingBranches] = useState(false); + + useEffect(() => { + if (modal.visible && projectId) { + setIsLoadingBranches(true); + projectsApi + .getBranches(projectId) + .then((result) => { + setBranches(result); + }) + .catch((err) => { + console.error('Failed to load branches:', err); + }) + .finally(() => { + setIsLoadingBranches(false); + }); + } + }, [modal.visible, projectId]); + + useEffect(() => { + if (!modal.visible) { + setSelectedProfile(null); + setSelectedBranch(null); + } + }, [modal.visible]); + + useEffect(() => { + if (!modal.visible) return; + + setSelectedProfile((prev) => { + if (prev) return prev; + + const fromAttempt: ExecutorProfileId | null = latestAttempt?.executor + ? { + executor: latestAttempt.executor as BaseCodingAgent, + variant: null, + } + : null; + + return fromAttempt ?? config?.executor_profile ?? null; + }); + + setSelectedBranch((prev) => { + if (prev) return prev; + return ( + latestAttempt?.target_branch ?? + branches.find((b) => b.is_current)?.name ?? + null + ); + }); + }, [ + modal.visible, + latestAttempt?.executor, + latestAttempt?.target_branch, + config?.executor_profile, + branches, + ]); + + const handleCreate = async () => { + if (!selectedProfile || !selectedBranch) return; + + try { + await createAttempt({ + profile: selectedProfile, + baseBranch: selectedBranch, + }); + modal.hide(); + } catch (err) { + console.error('Failed to create attempt:', err); + } + }; + + const canCreate = selectedProfile && selectedBranch && !isCreating; + + const handleOpenChange = (open: boolean) => { + if (!open) { + modal.hide(); + } + }; + + return ( + + + + {t('createAttemptDialog.title')} + + {t('createAttemptDialog.description')} + + + +
+ {profiles && ( +
+ +
+ )} + +
+ + +
+ + {error && ( +
+ {t('createAttemptDialog.error')} +
+ )} +
+ + + + + +
+
+ ); + } +); diff --git a/frontend/src/components/dialogs/tasks/ViewProcessesDialog.tsx b/frontend/src/components/dialogs/tasks/ViewProcessesDialog.tsx new file mode 100644 index 00000000..55224fb4 --- /dev/null +++ b/frontend/src/components/dialogs/tasks/ViewProcessesDialog.tsx @@ -0,0 +1,54 @@ +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import ProcessesTab from '@/components/tasks/TaskDetails/ProcessesTab'; +import { ProcessSelectionProvider } from '@/contexts/ProcessSelectionContext'; + +export interface ViewProcessesDialogProps { + attemptId: string; +} + +export const ViewProcessesDialog = NiceModal.create( + ({ attemptId }) => { + const { t } = useTranslation('tasks'); + const modal = useModal(); + + const handleOpenChange = (open: boolean) => { + if (!open) { + modal.hide(); + } + }; + + return ( + + { + if (e.key === 'Escape') { + e.stopPropagation(); + modal.hide(); + } + }} + > + + {t('viewProcessesDialog.title')} + +
+ + + +
+
+
+ ); + } +); diff --git a/frontend/src/components/diff-view-switch.tsx b/frontend/src/components/diff-view-switch.tsx index 9500a1cc..e1dd3f3e 100644 --- a/frontend/src/components/diff-view-switch.tsx +++ b/frontend/src/components/diff-view-switch.tsx @@ -1,66 +1,63 @@ import { Columns, FileText } from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { useDiffViewMode, useDiffViewStore } from '@/stores/useDiffViewStore'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; type Props = { className?: string; - size?: 'xs' | 'sm'; }; -/** - * Segmented switch for Inline vs Split diff modes. - * - Left segment: Inline (Unified) - * - Right segment: Split - * Uses global Zustand store so changing here updates all diffs. - */ -export default function DiffViewSwitch({ className, size = 'xs' }: Props) { +export default function DiffViewSwitch({ className }: Props) { + const { t } = useTranslation('tasks'); const mode = useDiffViewMode(); const setMode = useDiffViewStore((s) => s.setMode); - const isUnified = mode === 'unified'; - return ( -
- - -
+ + + + + + + + {t('diff.viewModes.inline')} + + + + + + + + + + + {t('diff.viewModes.split')} + + + + ); } diff --git a/frontend/src/components/layout/NormalLayout.tsx b/frontend/src/components/layout/NormalLayout.tsx new file mode 100644 index 00000000..f2910985 --- /dev/null +++ b/frontend/src/components/layout/NormalLayout.tsx @@ -0,0 +1,19 @@ +import { Outlet, useSearchParams } from 'react-router-dom'; +import { DevBanner } from '@/components/DevBanner'; +import { Navbar } from '@/components/layout/navbar'; + +export function NormalLayout() { + const [searchParams] = useSearchParams(); + const view = searchParams.get('view'); + const shouldHideNavbar = view === 'preview' || view === 'diffs'; + + return ( + <> + + {!shouldHideNavbar && } +
+ +
+ + ); +} diff --git a/frontend/src/components/layout/ResponsiveTwoPane.tsx b/frontend/src/components/layout/ResponsiveTwoPane.tsx new file mode 100644 index 00000000..365abd00 --- /dev/null +++ b/frontend/src/components/layout/ResponsiveTwoPane.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +interface ResponsiveTwoPaneProps { + left: React.ReactNode; + right: React.ReactNode; + isRightOpen: boolean; + variant?: 'sidebar' | 'split'; +} + +export function ResponsiveTwoPane({ + left, + right, + isRightOpen, + variant = 'sidebar', +}: ResponsiveTwoPaneProps) { + if (variant === 'split') { + return ( +
+
{left}
+
{right}
+
+ ); + } + + return ( +
+
{left}
+ + {isRightOpen && ( +
+ )} + + +
+ ); +} + +export default ResponsiveTwoPane; diff --git a/frontend/src/components/layout/TasksLayout.tsx b/frontend/src/components/layout/TasksLayout.tsx new file mode 100644 index 00000000..85d8cb0c --- /dev/null +++ b/frontend/src/components/layout/TasksLayout.tsx @@ -0,0 +1,350 @@ +import { ReactNode, useState } from 'react'; +import { PanelGroup, Panel, PanelResizeHandle } from 'react-resizable-panels'; +import { AnimatePresence, motion } from 'framer-motion'; + +export type LayoutMode = 'preview' | 'diffs' | null; + +interface TasksLayoutProps { + kanban: ReactNode; + attempt: ReactNode; + aux: ReactNode; + isPanelOpen: boolean; + mode: LayoutMode; + isMobile?: boolean; + rightHeader?: ReactNode; +} + +type SplitSizes = [number, number]; + +const MIN_PANEL_SIZE = 20; +const DEFAULT_KANBAN_ATTEMPT: SplitSizes = [66, 34]; +const DEFAULT_ATTEMPT_AUX: SplitSizes = [34, 66]; + +const STORAGE_KEYS = { + KANBAN_ATTEMPT: 'tasksLayout.desktop.v2.kanbanAttempt', + ATTEMPT_AUX: 'tasksLayout.desktop.v2.attemptAux', +} as const; + +function loadSizes(key: string, fallback: SplitSizes): SplitSizes { + try { + const saved = localStorage.getItem(key); + if (!saved) return fallback; + const parsed = JSON.parse(saved); + if (Array.isArray(parsed) && parsed.length === 2) + return parsed as SplitSizes; + return fallback; + } catch { + return fallback; + } +} + +function saveSizes(key: string, sizes: SplitSizes): void { + try { + localStorage.setItem(key, JSON.stringify(sizes)); + } catch { + // Ignore errors + } +} + +/** + * AuxRouter - Handles nested AnimatePresence for preview/diffs transitions. + */ +function AuxRouter({ mode, aux }: { mode: LayoutMode; aux: ReactNode }) { + return ( + + {mode && ( + + {aux} + + )} + + ); +} + +/** + * RightWorkArea - Contains header and Attempt/Aux content. + * Shows just Attempt when mode === null, or Attempt | Aux split when mode !== null. + */ +function RightWorkArea({ + attempt, + aux, + mode, + rightHeader, +}: { + attempt: ReactNode; + aux: ReactNode; + mode: LayoutMode; + rightHeader?: ReactNode; +}) { + const [innerSizes] = useState(() => + loadSizes(STORAGE_KEYS.ATTEMPT_AUX, DEFAULT_ATTEMPT_AUX) + ); + + return ( +
+ {rightHeader && ( +
+ {rightHeader} +
+ )} +
+ {mode === null ? ( + attempt + ) : ( + { + if (layout.length === 2) { + saveSizes(STORAGE_KEYS.ATTEMPT_AUX, [layout[0], layout[1]]); + } + }} + > + + {attempt} + + + +
+
+ + + +
+ + + + + + + )} +
+
+ ); +} + +/** + * DesktopSimple - Conditionally renders layout based on mode. + * When mode === null: Shows Kanban | Attempt + * When mode !== null: Hides Kanban, shows only RightWorkArea with Attempt | Aux + */ +function DesktopSimple({ + kanban, + attempt, + aux, + mode, + rightHeader, +}: { + kanban: ReactNode; + attempt: ReactNode; + aux: ReactNode; + mode: LayoutMode; + rightHeader?: ReactNode; +}) { + const [outerSizes] = useState(() => + loadSizes(STORAGE_KEYS.KANBAN_ATTEMPT, DEFAULT_KANBAN_ATTEMPT) + ); + + // When preview/diffs is open, hide Kanban entirely and render only RightWorkArea + if (mode !== null) { + return ( + + ); + } + + // When only viewing attempt logs, show Kanban | Attempt (no aux) + return ( + { + if (layout.length === 2) { + saveSizes(STORAGE_KEYS.KANBAN_ATTEMPT, [layout[0], layout[1]]); + } + }} + > + + {kanban} + + + +
+
+ + + +
+ + + + + + + ); +} + +export function TasksLayout({ + kanban, + attempt, + aux, + isPanelOpen, + mode, + isMobile = false, + rightHeader, +}: TasksLayoutProps) { + const desktopKey = isPanelOpen ? 'desktop-with-panel' : 'kanban-only'; + + if (isMobile) { + const columns = isPanelOpen ? ['0fr', '1fr', '0fr'] : ['1fr', '0fr', '0fr']; + const gridTemplateColumns = `minmax(0, ${columns[0]}) minmax(0, ${columns[1]}) minmax(0, ${columns[2]})`; + const isKanbanVisible = columns[0] !== '0fr'; + const isAttemptVisible = columns[1] !== '0fr'; + const isAuxVisible = columns[2] !== '0fr'; + + return ( +
+
+ {kanban} +
+ +
+ {attempt} +
+ +
+ {aux} +
+
+ ); + } + + let desktopNode: ReactNode; + + if (!isPanelOpen) { + desktopNode = ( +
+ {kanban} +
+ ); + } else { + desktopNode = ( + + ); + } + + return ( + + + {desktopNode} + + + ); +} diff --git a/frontend/src/components/layout/navbar.tsx b/frontend/src/components/layout/navbar.tsx index 1f94f111..8cde6c5d 100644 --- a/frontend/src/components/layout/navbar.tsx +++ b/frontend/src/components/layout/navbar.tsx @@ -1,5 +1,5 @@ import { Link, useLocation } from 'react-router-dom'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback } from 'react'; import { siDiscord } from 'simple-icons'; import { Button } from '@/components/ui/button'; import { @@ -26,8 +26,7 @@ import { useProject } from '@/contexts/project-context'; import { showProjectForm } from '@/lib/modals'; import { useOpenProjectInEditor } from '@/hooks/useOpenProjectInEditor'; import { OpenInIdeButton } from '@/components/ide/OpenInIdeButton'; - -const DISCORD_GUILD_ID = '1423630976524877857'; +import { useDiscordOnlineCount } from '@/hooks/useDiscordOnlineCount'; const INTERNAL_NAV = [ { label: 'Projects', icon: FolderOpen, to: '/projects' }, @@ -57,36 +56,7 @@ export function Navbar() { const { projectId, project } = useProject(); const { query, setQuery, active, clear, registerInputRef } = useSearch(); const handleOpenInEditor = useOpenProjectInEditor(project || null); - const [onlineCount, setOnlineCount] = useState(null); - - useEffect(() => { - let cancelled = false; - - const fetchCount = async () => { - try { - const res = await fetch( - `https://discord.com/api/guilds/${DISCORD_GUILD_ID}/widget.json`, - { cache: 'no-store' } - ); - if (!res.ok) return; // Widget disabled or temporary error; keep previous value - const data = await res.json(); - if (!cancelled && typeof data?.presence_count === 'number') { - setOnlineCount(data.presence_count); - } - } catch { - // Network error; ignore and keep previous value - } - }; - - // Initial fetch + refresh every 60s - fetchCount(); - const interval = setInterval(fetchCount, 60_000); - - return () => { - cancelled = true; - clearInterval(interval); - }; - }, []); + const { data: onlineCount } = useDiscordOnlineCount(); const setSearchBarRef = useCallback( (node: HTMLInputElement | null) => { @@ -143,7 +113,7 @@ export function Navbar() { className=" h-full items-center flex p-2" aria-live="polite" > - {onlineCount !== null + {onlineCount != null ? `${onlineCount.toLocaleString()} online` : 'online'} diff --git a/frontend/src/components/panels/AttemptHeaderActions.tsx b/frontend/src/components/panels/AttemptHeaderActions.tsx new file mode 100644 index 00000000..c2a4e183 --- /dev/null +++ b/frontend/src/components/panels/AttemptHeaderActions.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from 'react-i18next'; +import { Eye, FileDiff, X } from 'lucide-react'; +import { Button } from '../ui/button'; +import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '../ui/tooltip'; +import type { LayoutMode } from '../layout/TasksLayout'; +import type { TaskAttempt, TaskWithAttemptStatus } from 'shared/types'; +import { ActionsDropdown } from '../ui/ActionsDropdown'; + +interface AttemptHeaderActionsProps { + onClose: () => void; + mode?: LayoutMode; + onModeChange?: (mode: LayoutMode) => void; + task: TaskWithAttemptStatus; + attempt?: TaskAttempt | null; +} + +export const AttemptHeaderActions = ({ + onClose, + mode, + onModeChange, + task, + attempt, +}: AttemptHeaderActionsProps) => { + const { t } = useTranslation('tasks'); + return ( + <> + {typeof mode !== 'undefined' && onModeChange && ( + + onModeChange((v as LayoutMode) || null)} + className="inline-flex gap-4" + aria-label="Layout mode" + > + + + + + + + + {t('attemptHeaderActions.preview')} + + + + + + + + + + + {t('attemptHeaderActions.diffs')} + + + + + )} + {typeof mode !== 'undefined' && onModeChange && ( +
+ )} + + + + ); +}; diff --git a/frontend/src/components/tasks/TaskDetails/DiffTab.tsx b/frontend/src/components/panels/DiffsPanel.tsx similarity index 51% rename from frontend/src/components/tasks/TaskDetails/DiffTab.tsx rename to frontend/src/components/panels/DiffsPanel.tsx index a7a1269a..60b4ab57 100644 --- a/frontend/src/components/tasks/TaskDetails/DiffTab.tsx +++ b/frontend/src/components/panels/DiffsPanel.tsx @@ -1,17 +1,31 @@ import { useDiffStream } from '@/hooks/useDiffStream'; import { useMemo, useCallback, useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { Loader } from '@/components/ui/loader'; import { Button } from '@/components/ui/button'; import DiffViewSwitch from '@/components/diff-view-switch'; import DiffCard from '@/components/DiffCard'; import { useDiffSummary } from '@/hooks/useDiffSummary'; +import { NewCardHeader } from '@/components/ui/new-card'; +import { ChevronsUp, ChevronsDown } from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; import type { TaskAttempt } from 'shared/types'; +import GitOperations, { + type GitOperationsInputs, +} from '@/components/tasks/Toolbar/GitOperations.tsx'; -interface DiffTabProps { +interface DiffsPanelProps { selectedAttempt: TaskAttempt | null; + gitOps?: GitOperationsInputs; } -function DiffTab({ selectedAttempt }: DiffTabProps) { +export function DiffsPanel({ selectedAttempt, gitOps }: DiffsPanelProps) { + const { t } = useTranslation('tasks'); const [loading, setLoading] = useState(true); const [collapsedIds, setCollapsedIds] = useState>(new Set()); const [hasInitialized, setHasInitialized] = useState(false); @@ -85,29 +99,15 @@ function DiffTab({ selectedAttempt }: DiffTabProps) { if (error) { return (
-
Failed to load diff: {error}
-
- ); - } - - if (loading) { - return ( -
- -
- ); - } - - if (!loading && diffs.length === 0) { - return ( -
- No changes have been made yet +
+ {t('diff.errorLoadingDiff', { error })} +
); } return ( - ); } -interface DiffTabContentProps { +interface DiffsPanelContentProps { diffs: any[]; fileCount: number; added: number; @@ -131,9 +134,12 @@ interface DiffTabContentProps { handleCollapseAll: () => void; toggle: (id: string) => void; selectedAttempt: TaskAttempt | null; + gitOps?: GitOperationsInputs; + loading: boolean; + t: (key: string, params?: any) => string; } -function DiffTabContent({ +function DiffsPanelContent({ diffs, fileCount, added, @@ -143,55 +149,90 @@ function DiffTabContent({ handleCollapseAll, toggle, selectedAttempt, -}: DiffTabContentProps) { + gitOps, + loading, + t, +}: DiffsPanelContentProps) { return (
{diffs.length > 0 && ( -
-
+ + +
+ + + + + + + {allCollapsed ? t('diff.expandAll') : t('diff.collapseAll')} + + + + + } + > +
- {fileCount} file{fileCount === 1 ? '' : 's'} changed,{' '} - + {t('diff.filesChanged', { count: fileCount })}{' '} + +{added} {' '} - - -{deleted} - + -{deleted} -
- - -
+ + )} + {gitOps && selectedAttempt && ( +
+
)} -
- {diffs.map((diff, idx) => { - const id = diff.newPath || diff.oldPath || String(idx); - return ( - toggle(id)} - selectedAttempt={selectedAttempt} - /> - ); - })} +
+ {loading ? ( +
+ +
+ ) : diffs.length === 0 ? ( +
+ {t('diff.noChanges')} +
+ ) : ( + diffs.map((diff, idx) => { + const id = diff.newPath || diff.oldPath || String(idx); + return ( + toggle(id)} + selectedAttempt={selectedAttempt} + /> + ); + }) + )}
); } - -export default DiffTab; diff --git a/frontend/src/components/tasks/TaskDetails/PreviewTab.tsx b/frontend/src/components/panels/PreviewPanel.tsx similarity index 75% rename from frontend/src/components/tasks/TaskDetails/PreviewTab.tsx rename to frontend/src/components/panels/PreviewPanel.tsx index 19b2867e..2aa7aaeb 100644 --- a/frontend/src/components/tasks/TaskDetails/PreviewTab.tsx +++ b/frontend/src/components/panels/PreviewPanel.tsx @@ -1,30 +1,22 @@ import { useState, useEffect, useRef } from 'react'; +import { useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Loader2, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useDevserverPreview } from '@/hooks/useDevserverPreview'; import { useDevServer } from '@/hooks/useDevServer'; +import { useLogStream } from '@/hooks/useLogStream'; +import { useDevserverUrlFromLogs } from '@/hooks/useDevserverUrl'; import { ClickToComponentListener } from '@/utils/previewBridge'; import { useClickedElements } from '@/contexts/ClickedElementsProvider'; -import { TaskAttempt } from 'shared/types'; import { Alert } from '@/components/ui/alert'; import { useProject } from '@/contexts/project-context'; -import { DevServerLogsView } from './preview/DevServerLogsView'; -import { PreviewToolbar } from './preview/PreviewToolbar'; -import { NoServerContent } from './preview/NoServerContent'; -import { ReadyContent } from './preview/ReadyContent'; +import { DevServerLogsView } from '@/components/tasks/TaskDetails/preview/DevServerLogsView'; +import { PreviewToolbar } from '@/components/tasks/TaskDetails/preview/PreviewToolbar'; +import { NoServerContent } from '@/components/tasks/TaskDetails/preview/NoServerContent'; +import { ReadyContent } from '@/components/tasks/TaskDetails/preview/ReadyContent'; -interface PreviewTabProps { - selectedAttempt: TaskAttempt; - projectId: string; - projectHasDevScript: boolean; -} - -export default function PreviewTab({ - selectedAttempt, - projectId, - projectHasDevScript, -}: PreviewTabProps) { +export function PreviewPanel() { const [iframeError, setIframeError] = useState(false); const [isReady, setIsReady] = useState(false); const [loadingTimeFinished, setLoadingTimeFinished] = useState(false); @@ -33,14 +25,13 @@ export default function PreviewTab({ const [showLogs, setShowLogs] = useState(false); const listenerRef = useRef(null); - // Hooks const { t } = useTranslation('tasks'); - const { project } = useProject(); + const { project, projectId } = useProject(); + const { attemptId: rawAttemptId } = useParams<{ attemptId?: string }>(); - const previewState = useDevserverPreview(selectedAttempt.id, { - projectHasDevScript, - projectId, - }); + const attemptId = + rawAttemptId && rawAttemptId !== 'latest' ? rawAttemptId : undefined; + const projectHasDevScript = Boolean(project?.dev_script); const { start: startDevServer, @@ -49,7 +40,16 @@ export default function PreviewTab({ isStopping: isStoppingDevServer, runningDevServer, latestDevServerProcess, - } = useDevServer(selectedAttempt.id); + } = useDevServer(attemptId); + + const logStream = useLogStream(latestDevServerProcess?.id ?? ''); + const lastKnownUrl = useDevserverUrlFromLogs(logStream.logs); + + const previewState = useDevserverPreview(attemptId, { + projectHasDevScript, + projectId: projectId!, + lastKnownUrl, + }); const handleRefresh = () => { setIframeError(false); @@ -67,7 +67,6 @@ export default function PreviewTab({ } }; - // Set up message listener when iframe is ready useEffect(() => { if (previewState.status !== 'ready' || !previewState.url || !addElement) { return; @@ -104,7 +103,6 @@ export default function PreviewTab({ startTimer(); }, []); - // Auto-show help alert when having trouble loading preview useEffect(() => { if ( loadingTimeFinished && @@ -123,8 +121,17 @@ export default function PreviewTab({ runningDevServer, ]); - // Compute mode and unified logs handling - const mode = !runningDevServer ? 'noServer' : iframeError ? 'error' : 'ready'; + const isPreviewReady = + previewState.status === 'ready' && + Boolean(previewState.url) && + !iframeError; + const mode = iframeError + ? 'error' + : isPreviewReady + ? 'ready' + : runningDevServer + ? 'searching' + : 'noServer'; const toggleLogs = () => { setShowLogs((v) => !v); }; @@ -145,8 +152,19 @@ export default function PreviewTab({ }); }; + if (!attemptId) { + return ( +
+
+

{t('preview.title')}

+

{t('preview.selectAttempt')}

+
+
+ ); + } + return ( -
+
{mode === 'ready' ? ( <> @@ -155,6 +173,8 @@ export default function PreviewTab({ url={previewState.url} onRefresh={handleRefresh} onCopyUrl={handleCopyUrl} + onStop={stopDevServer} + isStopping={isStoppingDevServer} />
diff --git a/frontend/src/components/panels/TaskAttemptPanel.tsx b/frontend/src/components/panels/TaskAttemptPanel.tsx new file mode 100644 index 00000000..3cd2f658 --- /dev/null +++ b/frontend/src/components/panels/TaskAttemptPanel.tsx @@ -0,0 +1,45 @@ +import type { TaskAttempt, TaskWithAttemptStatus } from 'shared/types'; +import VirtualizedList from '@/components/logs/VirtualizedList'; +import { TaskFollowUpSection } from '@/components/tasks/TaskFollowUpSection'; +import { EntriesProvider } from '@/contexts/EntriesContext'; +import { RetryUiProvider } from '@/contexts/RetryUiContext'; +import type { ReactNode } from 'react'; + +interface TaskAttemptPanelProps { + attempt: TaskAttempt | undefined; + task: TaskWithAttemptStatus | null; + children: (sections: { logs: ReactNode; followUp: ReactNode }) => ReactNode; +} + +const TaskAttemptPanel = ({ + attempt, + task, + children, +}: TaskAttemptPanelProps) => { + if (!attempt) { + return
Loading attempt...
; + } + + if (!task) { + return
Loading task...
; + } + + return ( + + + {children({ + logs: , + followUp: ( + {}} + /> + ), + })} + + + ); +}; + +export default TaskAttemptPanel; diff --git a/frontend/src/components/panels/TaskPanel.tsx b/frontend/src/components/panels/TaskPanel.tsx new file mode 100644 index 00000000..e0370f88 --- /dev/null +++ b/frontend/src/components/panels/TaskPanel.tsx @@ -0,0 +1,167 @@ +import { useTranslation } from 'react-i18next'; +import { useProject } from '@/contexts/project-context'; +import { useTaskAttempts } from '@/hooks/useTaskAttempts'; +import { useNavigateWithSearch } from '@/hooks'; +import { paths } from '@/lib/paths'; +import type { TaskWithAttemptStatus } from 'shared/types'; +import { NewCardContent } from '../ui/new-card'; +import { Button } from '../ui/button'; +import { PlusIcon } from 'lucide-react'; +import NiceModal from '@ebay/nice-modal-react'; +import MarkdownRenderer from '@/components/ui/markdown-renderer'; + +interface TaskPanelProps { + task: TaskWithAttemptStatus | null; +} + +const TaskPanel = ({ task }: TaskPanelProps) => { + const { t } = useTranslation('tasks'); + const navigate = useNavigateWithSearch(); + const { projectId } = useProject(); + + const { + data: attempts = [], + isLoading: isAttemptsLoading, + isError: isAttemptsError, + } = useTaskAttempts(task?.id); + + const formatTimeAgo = (iso: string) => { + const d = new Date(iso); + const diffMs = Date.now() - d.getTime(); + const absSec = Math.round(Math.abs(diffMs) / 1000); + + const rtf = + typeof Intl !== 'undefined' && (Intl as any).RelativeTimeFormat + ? new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + : null; + + const to = (value: number, unit: Intl.RelativeTimeFormatUnit) => + rtf + ? rtf.format(-value, unit) + : `${value} ${unit}${value !== 1 ? 's' : ''} ago`; + + if (absSec < 60) return to(Math.round(absSec), 'second'); + const mins = Math.round(absSec / 60); + if (mins < 60) return to(mins, 'minute'); + const hours = Math.round(mins / 60); + if (hours < 24) return to(hours, 'hour'); + const days = Math.round(hours / 24); + if (days < 30) return to(days, 'day'); + const months = Math.round(days / 30); + if (months < 12) return to(months, 'month'); + const years = Math.round(months / 12); + return to(years, 'year'); + }; + + const displayedAttempts = [...attempts].sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + + const latestAttempt = displayedAttempts[0] ?? null; + + if (!task) { + return ( +
+ {t('taskPanel.noTaskSelected')} +
+ ); + } + + const titleContent = `# ${task.title || 'Task'}`; + const descriptionContent = task.description || ''; + + return ( + <> + +
+
+ + {descriptionContent && ( + + )} +
+ + {isAttemptsLoading && ( +
+ {t('taskPanel.loadingAttempts')} +
+ )} + {isAttemptsError && ( +
+ {t('taskPanel.errorLoadingAttempts')} +
+ )} + {!isAttemptsLoading && !isAttemptsError && ( + + + + + + + + {displayedAttempts.length === 0 ? ( + + + + ) : ( + displayedAttempts.map((attempt) => ( + { + if (projectId && task.id && attempt.id) { + navigate( + paths.attempt(projectId, task.id, attempt.id) + ); + } + }} + > + + + + + )) + )} + +
+
+ + {t('taskPanel.attemptsCount', { + count: displayedAttempts.length, + })} + + + + +
+
+ {t('taskPanel.noAttempts')} +
+ {attempt.executor || 'Base Agent'} + {attempt.branch || '—'} + {formatTimeAgo(attempt.created_at)} +
+ )} +
+
+ + ); +}; + +export default TaskPanel; diff --git a/frontend/src/components/panels/TaskPanelHeaderActions.tsx b/frontend/src/components/panels/TaskPanelHeaderActions.tsx new file mode 100644 index 00000000..af2d29fd --- /dev/null +++ b/frontend/src/components/panels/TaskPanelHeaderActions.tsx @@ -0,0 +1,25 @@ +import { Button } from '../ui/button'; +import { X } from 'lucide-react'; +import type { TaskWithAttemptStatus } from 'shared/types'; +import { ActionsDropdown } from '../ui/ActionsDropdown'; + +type Task = TaskWithAttemptStatus; + +interface TaskPanelHeaderActionsProps { + task: Task; + onClose: () => void; +} + +export const TaskPanelHeaderActions = ({ + task, + onClose, +}: TaskPanelHeaderActionsProps) => { + return ( + <> + + + + ); +}; diff --git a/frontend/src/components/projects/ProjectCard.tsx b/frontend/src/components/projects/ProjectCard.tsx index cab19b81..37645028 100644 --- a/frontend/src/components/projects/ProjectCard.tsx +++ b/frontend/src/components/projects/ProjectCard.tsx @@ -20,10 +20,10 @@ import { MoreHorizontal, Trash2, } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; import { Project } from 'shared/types'; import { useEffect, useRef } from 'react'; import { useOpenProjectInEditor } from '@/hooks/useOpenProjectInEditor'; +import { useNavigateWithSearch } from '@/hooks'; import { projectsApi } from '@/lib/api'; type Props = { @@ -41,7 +41,7 @@ function ProjectCard({ setError, onEdit, }: Props) { - const navigate = useNavigate(); + const navigate = useNavigateWithSearch(); const ref = useRef(null); const handleOpenInEditor = useOpenProjectInEditor(project); diff --git a/frontend/src/components/projects/github-repository-picker.tsx b/frontend/src/components/projects/github-repository-picker.tsx deleted file mode 100644 index 427fd461..00000000 --- a/frontend/src/components/projects/github-repository-picker.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Loader2, Github } from 'lucide-react'; -import { githubApi, RepositoryInfo } from '@/lib/api'; - -interface GitHubRepositoryPickerProps { - selectedRepository: RepositoryInfo | null; - onRepositorySelect: (repository: RepositoryInfo | null) => void; - onNameChange: (name: string) => void; - name: string; - error: string; -} - -// Simple in-memory cache for repositories -const repositoryCache = new Map(); -const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes -const cacheTimestamps = new Map(); - -function isCacheValid(page: number): boolean { - const timestamp = cacheTimestamps.get(page); - return timestamp ? Date.now() - timestamp < CACHE_DURATION : false; -} - -export function GitHubRepositoryPicker({ - selectedRepository, - onRepositorySelect, - onNameChange, - name, - error, -}: GitHubRepositoryPickerProps) { - const [repositories, setRepositories] = useState([]); - const [loading, setLoading] = useState(false); - const [loadError, setLoadError] = useState(''); - const [page, setPage] = useState(1); - const [hasMorePages, setHasMorePages] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const scrollContainerRef = useRef(null); - - const loadRepositories = useCallback( - async (pageNum: number = 1, isLoadingMore: boolean = false) => { - if (isLoadingMore) { - setLoadingMore(true); - } else { - setLoading(true); - } - setLoadError(''); - - try { - // Check cache first - if (isCacheValid(pageNum)) { - const cachedRepos = repositoryCache.get(pageNum); - if (cachedRepos) { - if (pageNum === 1) { - setRepositories(cachedRepos); - } else { - setRepositories((prev) => [...prev, ...cachedRepos]); - } - setPage(pageNum); - return; - } - } - - const repos = await githubApi.listRepositories(pageNum); - - // Cache the results - repositoryCache.set(pageNum, repos); - cacheTimestamps.set(pageNum, Date.now()); - - if (pageNum === 1) { - setRepositories(repos); - } else { - setRepositories((prev) => [...prev, ...repos]); - } - setPage(pageNum); - - // If we got fewer than expected results, we've reached the end - if (repos.length < 30) { - // GitHub typically returns 30 repos per page - setHasMorePages(false); - } - } catch (err) { - setLoadError( - err instanceof Error ? err.message : 'Failed to load repositories' - ); - } finally { - if (isLoadingMore) { - setLoadingMore(false); - } else { - setLoading(false); - } - } - }, - [] - ); - - useEffect(() => { - loadRepositories(1); - }, [loadRepositories]); - - const handleRepositorySelect = (repository: RepositoryInfo) => { - onRepositorySelect(repository); - // Auto-populate project name from repository name if name is empty - if (!name) { - const cleanName = repository.name - .replace(/[-_]/g, ' ') - .replace(/\b\w/g, (l) => l.toUpperCase()); - onNameChange(cleanName); - } - }; - - const loadMoreRepositories = useCallback(() => { - if (!loading && !loadingMore && hasMorePages) { - loadRepositories(page + 1, true); - } - }, [loading, loadingMore, hasMorePages, page, loadRepositories]); - - // Infinite scroll handler - const handleScroll = useCallback( - (e: React.UIEvent) => { - const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; - const isNearBottom = scrollHeight - scrollTop <= clientHeight + 100; // 100px threshold - - if (isNearBottom && !loading && !loadingMore && hasMorePages) { - loadMoreRepositories(); - } - }, - [loading, loadingMore, hasMorePages, loadMoreRepositories] - ); - - if (loadError) { - return ( - - - {loadError} - - - - ); - } - - return ( -
-
- - {loading && repositories.length === 0 ? ( -
- - Loading repositories... -
- ) : ( -
- {repositories.map((repository) => ( -
handleRepositorySelect(repository)} - > -
- -
-
- {repository.name} - {repository.private && ( - - Private - - )} -
-
-
{repository.full_name}
- {repository.description && ( -
{repository.description}
- )} -
-
-
-
- ))} - - {repositories.length === 0 && !loading && ( -
- No repositories found -
- )} - - {/* Loading more indicator */} - {loadingMore && ( -
- - - Loading more repositories... - -
- )} - - {/* Manual load more button (fallback if infinite scroll doesn't work) */} - {hasMorePages && !loadingMore && repositories.length > 0 && ( -
- -
- )} - - {/* End of results indicator */} - {!hasMorePages && repositories.length > 0 && ( -
- All repositories loaded -
- )} -
- )} -
- - {selectedRepository && ( -
- - onNameChange(e.target.value)} - /> -
- )} - - {error && ( - - {error} - - )} -
- ); -} diff --git a/frontend/src/components/projects/project-detail.tsx b/frontend/src/components/projects/project-detail.tsx index 40adc327..2f079a27 100644 --- a/frontend/src/components/projects/project-detail.tsx +++ b/frontend/src/components/projects/project-detail.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; +import { useNavigateWithSearch } from '@/hooks'; import { Card, CardContent, @@ -30,7 +30,7 @@ interface ProjectDetailProps { } export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { - const navigate = useNavigate(); + const navigate = useNavigateWithSearch(); const [project, setProject] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); diff --git a/frontend/src/components/tasks/AttemptHeaderCard.tsx b/frontend/src/components/tasks/AttemptHeaderCard.tsx deleted file mode 100644 index 8e486f2c..00000000 --- a/frontend/src/components/tasks/AttemptHeaderCard.tsx +++ /dev/null @@ -1,256 +0,0 @@ -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 { useBranchStatus } from '@/hooks'; -import { useAttemptExecution } from '@/hooks/useAttemptExecution'; -import { useMemo, useState } from 'react'; -import NiceModal from '@ebay/nice-modal-react'; -import { OpenInIdeButton } from '@/components/ide/OpenInIdeButton'; -import { useTranslation } from 'react-i18next'; - -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, - onJumpToDiffFullScreen, -}: AttemptHeaderCardProps) { - const { t } = useTranslation('tasks'); - const { - start: startDevServer, - stop: stopDevServer, - runningDevServer, - } = useDevServer(selectedAttempt?.id); - const rebaseMutation = useRebase(selectedAttempt?.id, projectId); - const mergeMutation = useMerge(selectedAttempt?.id); - const openInEditor = useOpenInEditor(selectedAttempt?.id); - const { fileCount, added, deleted } = useDiffSummary( - selectedAttempt?.id ?? null - ); - - // Branch status and execution state - const { data: branchStatus } = useBranchStatus(selectedAttempt?.id); - const { isAttemptRunning } = useAttemptExecution( - selectedAttempt?.id, - task.id - ); - - // Loading states - const [rebasing, setRebasing] = useState(false); - const [merging, setMerging] = useState(false); - - // Check for conflicts - const hasConflicts = useMemo( - () => Boolean((branchStatus?.conflicted_files?.length ?? 0) > 0), - [branchStatus?.conflicted_files] - ); - - // Merge status information - const mergeInfo = useMemo(() => { - if (!branchStatus?.merges) - return { - hasOpenPR: false, - openPR: null, - hasMergedPR: false, - mergedPR: null, - hasMerged: false, - }; - - const openPR = branchStatus.merges.find( - (m) => m.type === 'pr' && m.pr_info.status === 'open' - ); - - const mergedPR = branchStatus.merges.find( - (m) => m.type === 'pr' && m.pr_info.status === 'merged' - ); - - const merges = branchStatus.merges.filter( - (m) => - m.type === 'direct' || - (m.type === 'pr' && m.pr_info.status === 'merged') - ); - - return { - hasOpenPR: !!openPR, - openPR, - hasMergedPR: !!mergedPR, - mergedPR, - hasMerged: merges.length > 0, - }; - }, [branchStatus?.merges]); - - const handleCreatePR = () => { - if (selectedAttempt) { - NiceModal.show('create-pr', { - attempt: selectedAttempt, - task, - projectId, - }); - } - }; - - const handleRebaseClick = async () => { - setRebasing(true); - try { - await rebaseMutation.mutateAsync({}); - } catch (error) { - // Error handling is done by the mutation - } finally { - setRebasing(false); - } - }; - - const handleMergeClick = async () => { - setMerging(true); - try { - await mergeMutation.mutateAsync(); - } catch (error) { - // Error handling is done by the mutation - } finally { - setMerging(false); - } - }; - - return ( - -
-

- - {t('attempt.labels.attempt')} ·{' '} - - {attemptNumber}/{totalAttempts} -

-

- - {t('attempt.labels.agent')} ·{' '} - - {selectedAttempt?.executor} -

- {selectedAttempt?.branch && ( -

- - {t('attempt.labels.branch')} ·{' '} - - {selectedAttempt.branch} -

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

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

- )} -
- -
- openInEditor()} - disabled={!selectedAttempt} - className="h-10 w-10 p-0 shrink-0" - /> - - - - - - openInEditor()} - disabled={!selectedAttempt} - > - {t('attempt.actions.openInIde')} - - - runningDevServer ? stopDevServer() : startDevServer() - } - disabled={!selectedAttempt} - className={runningDevServer ? 'text-destructive' : ''} - > - {runningDevServer - ? t('attempt.actions.stopDevServer') - : t('attempt.actions.startDevServer')} - - {selectedAttempt && - branchStatus && - !mergeInfo.hasMergedPR && - (branchStatus.commits_behind ?? 0) > 0 && ( - - {rebasing - ? t('rebase.common.inProgress') - : t('rebase.common.action')} - - )} - - {t('git.states.createPr')} - - {selectedAttempt && branchStatus && !mergeInfo.hasMergedPR && ( - 0) || - isAttemptRunning || - (branchStatus.commits_ahead ?? 0) === 0 - } - > - {merging ? t('git.states.merging') : t('git.states.merge')} - - )} - {/* - Create new attempt - */} - - -
-
- ); -} diff --git a/frontend/src/components/tasks/TaskCard.tsx b/frontend/src/components/tasks/TaskCard.tsx index 0ec76afa..1605fe5b 100644 --- a/frontend/src/components/tasks/TaskCard.tsx +++ b/frontend/src/components/tasks/TaskCard.tsx @@ -1,22 +1,8 @@ import { useCallback, useEffect, useRef } from 'react'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; import { KanbanCard } from '@/components/ui/shadcn-io/kanban'; -import { - CheckCircle, - Copy, - Edit, - Loader2, - MoreHorizontal, - Trash2, - XCircle, -} from 'lucide-react'; +import { CheckCircle, Loader2, XCircle } from 'lucide-react'; import type { TaskWithAttemptStatus } from 'shared/types'; +import { ActionsDropdown } from '@/components/ui/ActionsDropdown'; type Task = TaskWithAttemptStatus; @@ -24,9 +10,6 @@ interface TaskCardProps { task: Task; index: number; status: string; - onEdit: (task: Task) => void; - onDelete: (taskId: string) => void; - onDuplicate?: (task: Task) => void; onViewDetails: (task: Task) => void; isOpen?: boolean; } @@ -35,9 +18,6 @@ export function TaskCard({ task, index, status, - onEdit, - onDelete, - onDuplicate, onViewDetails, isOpen, }: TaskCardProps) { @@ -93,36 +73,7 @@ export function TaskCard({ onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} > - - - - - - onEdit(task)}> - - Edit - - {onDuplicate && ( - onDuplicate(task)}> - - Duplicate - - )} - onDelete(task.id)} - className="text-destructive" - > - - Delete - - - +
diff --git a/frontend/src/components/tasks/TaskDetails/LogsTab.tsx b/frontend/src/components/tasks/TaskDetails/LogsTab.tsx deleted file mode 100644 index 72a0c169..00000000 --- a/frontend/src/components/tasks/TaskDetails/LogsTab.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { TaskAttempt } from 'shared/types'; -import VirtualizedList from '@/components/logs/VirtualizedList'; - -type Props = { - selectedAttempt: TaskAttempt; -}; - -function LogsTab({ selectedAttempt }: Props) { - return ; -} - -export default LogsTab; diff --git a/frontend/src/components/tasks/TaskDetails/ProcessLogsViewer.tsx b/frontend/src/components/tasks/TaskDetails/ProcessLogsViewer.tsx index eda1e999..f00e978a 100644 --- a/frontend/src/components/tasks/TaskDetails/ProcessLogsViewer.tsx +++ b/frontend/src/components/tasks/TaskDetails/ProcessLogsViewer.tsx @@ -11,16 +11,18 @@ interface ProcessLogsViewerProps { processId: string; } -export default function ProcessLogsViewer({ - processId, -}: ProcessLogsViewerProps) { +export function ProcessLogsViewerContent({ + logs, + error, +}: { + logs: LogEntry[]; + error: string | null; +}) { const virtuosoRef = useRef(null); const didInitScroll = useRef(false); const prevLenRef = useRef(0); const [atBottom, setAtBottom] = useState(true); - const { logs, error } = useLogStream(processId); - // 1) Initial jump to bottom once data appears. useEffect(() => { if (!didInitScroll.current && logs.length > 0) { @@ -93,3 +95,10 @@ export default function ProcessLogsViewer({
); } + +export default function ProcessLogsViewer({ + processId, +}: ProcessLogsViewerProps) { + const { logs, error } = useLogStream(processId); + return ; +} diff --git a/frontend/src/components/tasks/TaskDetails/ProcessesTab.tsx b/frontend/src/components/tasks/TaskDetails/ProcessesTab.tsx index 0ae73a9e..d8814dfa 100644 --- a/frontend/src/components/tasks/TaskDetails/ProcessesTab.tsx +++ b/frontend/src/components/tasks/TaskDetails/ProcessesTab.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { Play, Square, @@ -22,6 +23,7 @@ interface ProcessesTabProps { } function ProcessesTab({ attemptId }: ProcessesTabProps) { + const { t } = useTranslation('tasks'); const { executionProcesses, executionProcessesById, @@ -135,7 +137,7 @@ function ProcessesTab({ attemptId }: ProcessesTabProps) {
-

Select an attempt to view execution processes.

+

{t('processes.selectAttempt')}

); @@ -147,19 +149,19 @@ function ProcessesTab({ attemptId }: ProcessesTabProps) {
{processesError && (
- Failed to load live updates for processes. - {!isConnected && ' Reconnecting...'} + {t('processes.errorLoadingUpdates')} + {!isConnected && ` ${t('processes.reconnecting')}`}
)} {processesLoading && executionProcesses.length === 0 ? (
-

Loading execution processes...

+

{t('processes.loading')}

) : executionProcesses.length === 0 ? (
-

No execution processes found for this attempt.

+

{t('processes.noProcesses')}

) : ( @@ -177,26 +179,29 @@ function ProcessesTab({ attemptId }: ProcessesTabProps) { onClick={() => handleProcessClick(process)} >
-
+
{getStatusIcon(process.status)} -
+

{process.run_reason}

-

- Process ID: {process.id} +

+ {t('processes.processId', { id: process.id })}

{process.dropped && ( - Deleted + {t('processes.deleted')} )} {

- Agent:{' '} + {t('processes.agent')}{' '} {process.executor_action.typ.type === 'CodingAgentInitialRequest' || process.executor_action.typ.type === @@ -222,21 +227,28 @@ function ProcessesTab({ attemptId }: ProcessesTabProps) { {process.exit_code !== null && (

- Exit: {process.exit_code.toString()} + {t('processes.exit', { + code: process.exit_code.toString(), + })}

)}
- Started: {formatDate(process.started_at)} + + {t('processes.started', { + date: formatDate(process.started_at), + })} + {process.completed_at && ( - Completed: {formatDate(process.completed_at)} + {t('processes.completed', { + date: formatDate(process.completed_at), + })} )}
-
Process ID: {process.id}
))} @@ -246,13 +258,15 @@ function ProcessesTab({ attemptId }: ProcessesTabProps) { ) : (
-

Process Details

+

+ {t('processes.detailsTitle')} +

@@ -260,11 +274,11 @@ function ProcessesTab({ attemptId }: ProcessesTabProps) { ) : loadingProcessId === selectedProcessId ? (
-

Loading process details...

+

{t('processes.loadingDetails')}

) : (
-

Failed to load process details. Please try again.

+

{t('processes.errorLoadingDetails')}

)}
diff --git a/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx b/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx deleted file mode 100644 index 03fcc10c..00000000 --- a/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { GitCompare, MessageSquare, Cog, Monitor } from 'lucide-react'; -import type { TabType } from '@/types/tabs'; -import type { TaskAttempt } from 'shared/types'; - -type Props = { - activeTab: TabType; - setActiveTab: (tab: TabType) => void; - rightContent?: React.ReactNode; - selectedAttempt: TaskAttempt | null; - showPreview?: boolean; - previewStatus?: 'idle' | 'searching' | 'ready' | 'error'; -}; - -function TabNavigation({ activeTab, setActiveTab, rightContent }: Props) { - const tabs = [ - { id: 'logs' as TabType, label: 'Logs', icon: MessageSquare }, - { id: 'diffs' as TabType, label: 'Diffs', icon: GitCompare }, - { id: 'processes' as TabType, label: 'Processes', icon: Cog }, - { id: 'preview' as TabType, label: 'Preview', icon: Monitor }, - ]; - - const getTabClassName = (tabId: TabType) => { - const baseClasses = 'flex items-center py-2 px-2 text-sm font-medium'; - const activeClasses = 'text-primary-foreground'; - const inactiveClasses = - 'text-secondary-foreground hover:text-primary-foreground'; - - return `${baseClasses} ${activeTab === tabId ? activeClasses : inactiveClasses}`; - }; - - return ( -
-
- {tabs.map(({ id, label, icon: Icon }) => ( - - ))} -
{rightContent}
-
-
- ); -} - -export default TabNavigation; diff --git a/frontend/src/components/tasks/TaskDetails/TaskTitleDescription.tsx b/frontend/src/components/tasks/TaskDetails/TaskTitleDescription.tsx deleted file mode 100644 index 7caed9fc..00000000 --- a/frontend/src/components/tasks/TaskDetails/TaskTitleDescription.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useState } from 'react'; -import { ChevronDown, ChevronUp } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import type { TaskWithAttemptStatus } from 'shared/types'; - -interface TaskTitleDescriptionProps { - task: TaskWithAttemptStatus; -} - -export function TaskTitleDescription({ task }: TaskTitleDescriptionProps) { - const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); - - return ( -
-

{task.title}

- -
-
- {task.description ? ( -
-

350 - ? 'line-clamp-6' - : '' - }`} - > - {task.description} -

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

No description provided

- )} -
-
-
- ); -} diff --git a/frontend/src/components/tasks/TaskDetails/preview/DevServerLogsView.tsx b/frontend/src/components/tasks/TaskDetails/preview/DevServerLogsView.tsx index 10f88d71..23032d8b 100644 --- a/frontend/src/components/tasks/TaskDetails/preview/DevServerLogsView.tsx +++ b/frontend/src/components/tasks/TaskDetails/preview/DevServerLogsView.tsx @@ -1,7 +1,9 @@ import { useTranslation } from 'react-i18next'; import { Terminal, ChevronDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import ProcessLogsViewer from '../ProcessLogsViewer'; +import ProcessLogsViewer, { + ProcessLogsViewerContent, +} from '../ProcessLogsViewer'; import { ExecutionProcess } from 'shared/types'; interface DevServerLogsViewProps { @@ -10,6 +12,8 @@ interface DevServerLogsViewProps { onToggle: () => void; height?: string; showToggleText?: boolean; + logs?: Array<{ type: 'STDOUT' | 'STDERR'; content: string }>; + error?: string | null; } export function DevServerLogsView({ @@ -18,6 +22,8 @@ export function DevServerLogsView({ onToggle, height = 'h-60', showToggleText = true, + logs, + error, }: DevServerLogsViewProps) { const { t } = useTranslation('tasks'); @@ -50,7 +56,11 @@ export function DevServerLogsView({ {/* Logs viewer */} {showLogs && (
- + {logs ? ( + + ) : ( + + )}
)}
diff --git a/frontend/src/components/tasks/TaskDetails/preview/NoServerContent.tsx b/frontend/src/components/tasks/TaskDetails/preview/NoServerContent.tsx index 5484de7c..ab469922 100644 --- a/frontend/src/components/tasks/TaskDetails/preview/NoServerContent.tsx +++ b/frontend/src/components/tasks/TaskDetails/preview/NoServerContent.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Play, Edit3, + Square, SquareTerminal, Save, X, @@ -169,8 +170,17 @@ export function NoServerContent({ disabled={isStartingDevServer || !projectHasDevScript} className="gap-1" > - - {t('preview.noServer.startButton')} + {runningDevServer ? ( + <> + + {t('preview.toolbar.stopDevServer')} + + ) : ( + <> + + {t('preview.noServer.startButton')} + + )} {!runningDevServer && ( diff --git a/frontend/src/components/tasks/TaskDetails/preview/PreviewToolbar.tsx b/frontend/src/components/tasks/TaskDetails/preview/PreviewToolbar.tsx index ca7aa448..576b6603 100644 --- a/frontend/src/components/tasks/TaskDetails/preview/PreviewToolbar.tsx +++ b/frontend/src/components/tasks/TaskDetails/preview/PreviewToolbar.tsx @@ -1,4 +1,4 @@ -import { ExternalLink, RefreshCw, Copy, Loader2 } from 'lucide-react'; +import { ExternalLink, RefreshCw, Copy, Loader2, Pause } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { @@ -7,12 +7,15 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; +import { NewCardHeader } from '@/components/ui/new-card'; interface PreviewToolbarProps { mode: 'noServer' | 'error' | 'ready'; url?: string; onRefresh: () => void; onCopyUrl: () => void; + onStop: () => void; + isStopping?: boolean; } export function PreviewToolbar({ @@ -20,60 +23,110 @@ export function PreviewToolbar({ url, onRefresh, onCopyUrl, + onStop, + isStopping, }: PreviewToolbarProps) { const { t } = useTranslation('tasks'); - return ( -
- - {url || } - - {mode !== 'noServer' && ( - <> - - - - - - {t('preview.toolbar.refresh')} - - - - - - + + + {t('preview.toolbar.refresh')} + + + + + + + + + + + {t('preview.toolbar.copyUrl')} + + + + + + + + - - {t('preview.toolbar.copyUrl')} - - - - - - - - {t('preview.toolbar.openInTab')} - - - - )} -
+ + + + + + {t('preview.toolbar.openInTab')} + + + + +
+ + + + + + + + {t('preview.toolbar.stopDevServer')} + + + + + ) : undefined; + + return ( + +
+ + {url || } + +
+
); } diff --git a/frontend/src/components/tasks/TaskDetailsHeader.tsx b/frontend/src/components/tasks/TaskDetailsHeader.tsx deleted file mode 100644 index 0ccc803e..00000000 --- a/frontend/src/components/tasks/TaskDetailsHeader.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { memo } from 'react'; -import { Edit, Trash2, X, Maximize2, Minimize2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import type { TaskWithAttemptStatus } from 'shared/types'; -import { TaskTitleDescription } from './TaskDetails/TaskTitleDescription'; -import { Card } from '../ui/card'; -import { statusBoardColors, statusLabels } from '@/utils/status-labels'; -import { useTaskViewManager } from '@/hooks/useTaskViewManager'; - -interface TaskDetailsHeaderProps { - task: TaskWithAttemptStatus; - onClose: () => void; - onEditTask?: (task: TaskWithAttemptStatus) => void; - onDeleteTask?: (taskId: string) => void; - hideCloseButton?: boolean; - isFullScreen?: boolean; -} - -// backgroundColor: `hsl(var(${statusBoardColors[task.status]}) / 0.03)`, - -function TaskDetailsHeader({ - task, - onClose, - onEditTask, - onDeleteTask, - hideCloseButton = false, - isFullScreen, -}: TaskDetailsHeaderProps) { - const { toggleFullscreen } = useTaskViewManager(); - return ( -
- -
-
-

{statusLabels[task.status]}

-
-
- - - - - - -

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

-
-
-
- {onEditTask && ( - - - - - - -

Edit task

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

Delete task

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

Close panel

-
-
-
- )} -
- - - {/* Title and Task Actions */} - {!isFullScreen && ( -
- -
- )} -
- ); -} - -export default memo(TaskDetailsHeader); diff --git a/frontend/src/components/tasks/TaskDetailsPanel.tsx b/frontend/src/components/tasks/TaskDetailsPanel.tsx deleted file mode 100644 index 671cb1c0..00000000 --- a/frontend/src/components/tasks/TaskDetailsPanel.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { useEffect, useState } from 'react'; -import TaskDetailsHeader from './TaskDetailsHeader'; -import { TaskFollowUpSection } from './TaskFollowUpSection'; -import { TaskTitleDescription } from './TaskDetails/TaskTitleDescription'; -import { - getBackdropClasses, - getTaskPanelClasses, - getTaskPanelInnerClasses, -} from '@/lib/responsive-config'; -import type { TaskWithAttemptStatus } from 'shared/types'; -import type { TabType } from '@/types/tabs'; -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 PreviewTab from '@/components/tasks/TaskDetails/PreviewTab.tsx'; -import TabNavigation from '@/components/tasks/TaskDetails/TabNavigation.tsx'; -import TaskDetailsToolbar from './TaskDetailsToolbar.tsx'; -import TodoPanel from '@/components/tasks/TodoPanel'; -import { TabNavContext } from '@/contexts/TabNavigationContext'; -import { ProcessSelectionProvider } from '@/contexts/ProcessSelectionContext'; -import { ReviewProvider } from '@/contexts/ReviewProvider'; -import { ClickedElementsProvider } from '@/contexts/ClickedElementsProvider'; -import { EntriesProvider } from '@/contexts/EntriesContext'; -import { RetryUiProvider } from '@/contexts/RetryUiContext'; -import { AttemptHeaderCard } from './AttemptHeaderCard'; -import { inIframe } from '@/vscode/bridge'; -import { TaskRelationshipViewer } from './TaskRelationshipViewer'; -import { useTaskViewManager } from '@/hooks/useTaskViewManager.ts'; -import type { TaskAttempt } from 'shared/types'; - -interface TaskDetailsPanelProps { - task: TaskWithAttemptStatus | null; - projectHasDevScript: boolean; - projectId: string; - onClose: () => void; - onEditTask?: (task: TaskWithAttemptStatus) => void; - onDeleteTask?: (taskId: string) => void; - onNavigateToTask?: (taskId: string) => void; - hideBackdrop?: boolean; - className?: string; - hideHeader?: boolean; - isFullScreen?: boolean; - forceCreateAttempt?: boolean; - onLeaveForceCreateAttempt?: () => void; - onNewAttempt?: () => void; - selectedAttempt: TaskAttempt | null; - attempts: TaskAttempt[]; - setSelectedAttempt: (attempt: TaskAttempt | null) => void; - tasksById?: Record; -} - -export function TaskDetailsPanel({ - task, - projectHasDevScript, - projectId, - onClose, - onEditTask, - onDeleteTask, - onNavigateToTask, - hideBackdrop = false, - className, - isFullScreen, - forceCreateAttempt, - onLeaveForceCreateAttempt, - selectedAttempt, - attempts, - setSelectedAttempt, - tasksById, -}: TaskDetailsPanelProps) { - // 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 { toggleFullscreen } = useTaskViewManager(); - - const jumpToDiffFullScreen = () => { - toggleFullscreen(true); - setActiveTab('diffs'); - }; - - const jumpToLogsTab = () => { - setActiveTab('logs'); - }; - - // Reset to logs tab when task changes - useEffect(() => { - if (task?.id) { - setActiveTab('logs'); - } - }, [task?.id]); - - return ( - <> - {!task ? null : ( - - - - - - {/* Backdrop - only on smaller screens (overlay mode) */} - {!hideBackdrop && ( -
- )} - - {/* Panel */} -
-
- {!inIframe() && ( - - )} - - {isFullScreen ? ( -
- {/* Sidebar */} - - - {/* Main content */} -
- {selectedAttempt && ( - - <> - - -
- {activeTab === 'diffs' ? ( - - ) : activeTab === 'processes' ? ( - - ) : activeTab === 'preview' ? ( - - ) : ( - - )} -
- - - -
- )} -
-
- ) : ( - <> - {attempts.length === 0 ? ( - - ) : ( - <> - - - {selectedAttempt && ( - - - - - )} - - )} - - )} -
-
- - - - - - )} - - ); -} diff --git a/frontend/src/components/tasks/TaskDetailsToolbar.tsx b/frontend/src/components/tasks/TaskDetailsToolbar.tsx deleted file mode 100644 index 3ca34ac2..00000000 --- a/frontend/src/components/tasks/TaskDetailsToolbar.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Play } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { projectsApi, attemptsApi } from '@/lib/api'; -import type { - GitBranch, - TaskAttempt, - TaskWithAttemptStatus, -} from 'shared/types'; -import type { ExecutorProfileId } from 'shared/types'; - -import { useAttemptExecution, useBranchStatus } from '@/hooks'; -import { useTaskStopping } from '@/stores/useTaskDetailsUiStore'; - -import CreateAttempt from '@/components/tasks/Toolbar/CreateAttempt.tsx'; -import CurrentAttempt from '@/components/tasks/Toolbar/CurrentAttempt.tsx'; -import GitOperations from '@/components/tasks/Toolbar/GitOperations.tsx'; -import { useUserSystem } from '@/components/config-provider'; -import { Card } from '../ui/card'; - -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; -}) { - // Use props instead of context - const taskAttempts = attempts; - // const { setLoading } = useTaskLoading(task.id); - const { isStopping } = useTaskStopping(task.id); - const { isAttemptRunning } = useAttemptExecution(selectedAttempt?.id); - const { data: branchStatus } = useBranchStatus(selectedAttempt?.id); - - // UI state - const [userForcedCreateMode, setUserForcedCreateMode] = useState(false); - const [error, setError] = useState(null); - - // Data state - const [branches, setBranches] = useState([]); - const [selectedBranch, setSelectedBranch] = useState(null); - const [selectedProfile, setSelectedProfile] = - useState(null); - const [parentBaseBranch, setParentBaseBranch] = useState(null); - // const { attemptId: urlAttemptId } = useParams<{ attemptId?: string }>(); - const { system, profiles } = useUserSystem(); - - // Memoize latest attempt calculation - const latestAttempt = useMemo(() => { - if (taskAttempts.length === 0) return null; - return taskAttempts.reduce((latest, current) => - new Date(current.created_at) > new Date(latest.created_at) - ? current - : latest - ); - }, [taskAttempts]); - - // Derived state - const isInCreateAttemptMode = - forceCreateAttempt ?? (userForcedCreateMode || taskAttempts.length === 0); - - // Derive createAttemptBranch for backward compatibility - const createAttemptBranch = useMemo(() => { - // Priority order: - // 1. User explicitly selected a branch - if (selectedBranch) { - return selectedBranch; - } - - // 2. Latest attempt's base branch (existing behavior for resume/rerun) - if ( - latestAttempt?.target_branch && - branches.some((b: GitBranch) => b.name === latestAttempt.target_branch) - ) { - return latestAttempt.target_branch; - } - - // 3. Parent task attempt's base branch (NEW - for inherited tasks) - if (parentBaseBranch) { - return parentBaseBranch; - } - - // 4. Fall back to current branch - const currentBranch = branches.find((b) => b.is_current); - return currentBranch?.name || null; - }, [latestAttempt, branches, selectedBranch, parentBaseBranch]); - - const fetchProjectBranches = useCallback(async () => { - const result = await projectsApi.getBranches(projectId); - - setBranches(result); - }, [projectId]); - - useEffect(() => { - fetchProjectBranches(); - }, [fetchProjectBranches]); - - // Set default executor from config - useEffect(() => { - if (system.config?.executor_profile) { - setSelectedProfile(system.config.executor_profile); - } - }, [system.config?.executor_profile]); - - // Fetch parent task attempt's base branch - useEffect(() => { - if (task.parent_task_attempt) { - attemptsApi - .get(task.parent_task_attempt) - .then((attempt) => setParentBaseBranch(attempt.branch)) - .catch(() => setParentBaseBranch(null)); - } else { - setParentBaseBranch(null); - } - }, [task.parent_task_attempt]); - - // Simplified - hooks handle data fetching and navigation - // const fetchTaskAttempts = useCallback(() => { - // // The useSelectedAttempt hook handles all this logic now - // }, []); - - // Remove fetchTaskAttempts - hooks handle this now - - // Handle entering create attempt mode - const handleEnterCreateAttemptMode = useCallback(() => { - setUserForcedCreateMode(true); - }, []); - - const setIsInCreateAttemptMode = useCallback( - (value: boolean | ((prev: boolean) => boolean)) => { - const boolValue = - typeof value === 'function' ? value(isInCreateAttemptMode) : value; - if (boolValue) { - setUserForcedCreateMode(true); - } else { - if (onLeaveForceCreateAttempt) onLeaveForceCreateAttempt(); - setUserForcedCreateMode(false); - } - }, - [isInCreateAttemptMode, onLeaveForceCreateAttempt] - ); - - return ( - <> -
- {/* Error Display */} - {error && ( -
-
{error}
-
- )} - - {isInCreateAttemptMode ? ( - - ) : ( -
- - 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 && ( -
- -
- )} -
-
- )} - - {/* Independent Git Operations Section */} - {selectedAttempt && ( - - )} -
- - ); -} - -export default TaskDetailsToolbar; diff --git a/frontend/src/components/tasks/TaskFollowUpSection.tsx b/frontend/src/components/tasks/TaskFollowUpSection.tsx index 5b9af4bc..02599a3e 100644 --- a/frontend/src/components/tasks/TaskFollowUpSection.tsx +++ b/frontend/src/components/tasks/TaskFollowUpSection.tsx @@ -41,6 +41,7 @@ import { useFollowUpSend } from '@/hooks/follow-up/useFollowUpSend'; import { useDefaultVariant } from '@/hooks/follow-up/useDefaultVariant'; import { buildResolveConflictsInstructions } from '@/lib/conflicts'; import { appendImageMarkdown } from '@/utils/markdownImages'; +import { useTranslation } from 'react-i18next'; interface TaskFollowUpSectionProps { task: TaskWithAttemptStatus; @@ -53,6 +54,8 @@ export function TaskFollowUpSection({ selectedAttemptId, jumpToLogsTab, }: TaskFollowUpSectionProps) { + const { t } = useTranslation('tasks'); + const { isAttemptRunning, stopExecution, isStopping, processes } = useAttemptExecution(selectedAttemptId, task.id); const { data: branchStatus, refetch: refetchBranchStatus } = @@ -410,7 +413,7 @@ export function TaskFollowUpSection({ selectedAttemptId && (
@@ -538,7 +541,7 @@ export function TaskFollowUpSection({ ) : ( <> - Stop + {t('followUp.stop')} )} @@ -551,7 +554,7 @@ export function TaskFollowUpSection({ variant="destructive" disabled={!isEditable} > - Clear Review Comments + {t('followUp.clearReviewComments')} )} @@ -595,10 +598,10 @@ export function TaskFollowUpSection({ {isUnqueuing ? ( <> - Unqueuing… + {t('followUp.unqueuing')} ) : ( - 'Edit' + t('followUp.edit') )} )} @@ -644,18 +647,18 @@ export function TaskFollowUpSection({ isUnqueuing ? ( <> - Unqueuing… + {t('followUp.unqueuing')} ) : ( - 'Edit' + t('followUp.edit') ) ) : isQueuing ? ( <> - Queuing… + {t('followUp.queuing')} ) : ( - 'Queue for next turn' + t('followUp.queueForNextTurn') )}
diff --git a/frontend/src/components/tasks/TaskKanbanBoard.tsx b/frontend/src/components/tasks/TaskKanbanBoard.tsx index 7d9a6f14..3d8e2119 100644 --- a/frontend/src/components/tasks/TaskKanbanBoard.tsx +++ b/frontend/src/components/tasks/TaskKanbanBoard.tsx @@ -17,9 +17,6 @@ type Task = TaskWithAttemptStatus; interface TaskKanbanBoardProps { groupedTasks: Record; onDragEnd: (event: DragEndEvent) => void; - onEditTask: (task: Task) => void; - onDeleteTask: (taskId: string) => void; - onDuplicateTask?: (task: Task) => void; onViewTaskDetails: (task: Task) => void; selectedTask?: Task; onCreateTask?: () => void; @@ -28,9 +25,6 @@ interface TaskKanbanBoardProps { function TaskKanbanBoard({ groupedTasks, onDragEnd, - onEditTask, - onDeleteTask, - onDuplicateTask, onViewTaskDetails, selectedTask, onCreateTask, @@ -51,9 +45,6 @@ function TaskKanbanBoard({ task={task} index={index} status={status} - onEdit={onEditTask} - onDelete={onDeleteTask} - onDuplicate={onDuplicateTask} onViewDetails={onViewTaskDetails} isOpen={selectedTask?.id === task.id} /> diff --git a/frontend/src/components/tasks/TodoPanel.tsx b/frontend/src/components/tasks/TodoPanel.tsx index 1b07195f..5e47e34d 100644 --- a/frontend/src/components/tasks/TodoPanel.tsx +++ b/frontend/src/components/tasks/TodoPanel.tsx @@ -1,30 +1,52 @@ -import { Circle, CircleCheckBig, CircleDotDashed } from 'lucide-react'; +import { Circle, Check, CircleDot, ChevronDown } from 'lucide-react'; import { useEntries } from '@/contexts/EntriesContext'; import { usePinnedTodos } from '@/hooks/usePinnedTodos'; import { Card } from '../ui/card'; +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +const TODO_PANEL_OPEN_KEY = 'todo-panel-open'; function getStatusIcon(status?: string) { const s = (status || '').toLowerCase(); if (s === 'completed') - return ; + return ; if (s === 'in_progress' || s === 'in-progress') - return ; + return ; return ; } function TodoPanel() { + const { t } = useTranslation('tasks'); const { entries } = useEntries(); const { todos } = usePinnedTodos(entries); + const [isOpen, setIsOpen] = useState(() => { + const stored = localStorage.getItem(TODO_PANEL_OPEN_KEY); + return stored === null ? true : stored === 'true'; + }); + + useEffect(() => { + localStorage.setItem(TODO_PANEL_OPEN_KEY, String(isOpen)); + }, [isOpen]); - // Only show once the agent has created subtasks if (!todos || todos.length === 0) return null; return ( -
- - Todos - -
+
setIsOpen(e.currentTarget.open)} + > + + + {t('todos.title', { count: todos.length })} + + + +
    {todos.map((todo, index) => (
-
+ ); } diff --git a/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx b/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx deleted file mode 100644 index 7e4725ce..00000000 --- a/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { Dispatch, SetStateAction, useCallback } from 'react'; -import { Button } from '@/components/ui/button.tsx'; -import { X } from 'lucide-react'; -import type { GitBranch, Task } from 'shared/types'; -import type { ExecutorConfig } from 'shared/types'; -import type { ExecutorProfileId } from 'shared/types'; -import type { TaskAttempt } from 'shared/types'; -import { useAttemptCreation } from '@/hooks/useAttemptCreation'; -import { useAttemptExecution } from '@/hooks/useAttemptExecution'; -import BranchSelector from '@/components/tasks/BranchSelector.tsx'; -import { ExecutorProfileSelector } from '@/components/settings'; - -import { showModal } from '@/lib/modals'; -import { Card } from '@/components/ui/card'; -import { Label } from '@/components/ui/label'; - -type Props = { - task: Task; - branches: GitBranch[]; - taskAttempts: TaskAttempt[]; - createAttemptBranch: string | null; - selectedProfile: ExecutorProfileId | null; - selectedBranch: string | null; - setIsInCreateAttemptMode: Dispatch>; - setCreateAttemptBranch: (branch: string | null) => void; - setSelectedProfile: Dispatch>; - availableProfiles: Record | null; - selectedAttempt: TaskAttempt | null; -}; - -function CreateAttempt({ - task, - branches, - taskAttempts, - createAttemptBranch, - selectedProfile, - selectedBranch, - setIsInCreateAttemptMode, - setCreateAttemptBranch, - setSelectedProfile, - availableProfiles, - selectedAttempt, -}: Props) { - const { isAttemptRunning } = useAttemptExecution(selectedAttempt?.id); - const { createAttempt, isCreating } = useAttemptCreation(task.id); - - // Create attempt logic - const actuallyCreateAttempt = useCallback( - async (profile: ExecutorProfileId, baseBranch?: string) => { - const effectiveBaseBranch = baseBranch || selectedBranch; - - if (!effectiveBaseBranch) { - throw new Error('Base branch is required to create an attempt'); - } - - await createAttempt({ - profile, - baseBranch: effectiveBaseBranch, - }); - }, - [createAttempt, selectedBranch] - ); - - // Handler for Enter key or Start button - const onCreateNewAttempt = useCallback( - async ( - profile: ExecutorProfileId, - baseBranch?: string, - isKeyTriggered?: boolean - ) => { - if (task.status === 'todo' && isKeyTriggered) { - try { - const result = await showModal<'confirmed' | 'canceled'>( - 'create-attempt-confirm', - { - title: 'Start New Attempt?', - message: - 'Are you sure you want to start a new attempt for this task? This will create a new session and branch.', - } - ); - - if (result === 'confirmed') { - await actuallyCreateAttempt(profile, baseBranch); - setIsInCreateAttemptMode(false); - } - } catch (error) { - // User cancelled - do nothing - } - } else { - await actuallyCreateAttempt(profile, baseBranch); - setIsInCreateAttemptMode(false); - } - }, - [task.status, actuallyCreateAttempt, setIsInCreateAttemptMode] - ); - - const handleExitCreateAttemptMode = () => { - setIsInCreateAttemptMode(false); - }; - - const handleCreateAttempt = () => { - if (!selectedProfile) { - return; - } - onCreateNewAttempt(selectedProfile, createAttemptBranch || undefined); - }; - - return ( -
- - Create Attempt - -
-
- {taskAttempts.length > 0 && ( - - )} -
-
- -
- -
- {/* Top Row: Executor Profile and Variant (spans 2 columns) */} - {availableProfiles && ( -
- -
- )} - - {/* Bottom Row: Base Branch and Start Button */} -
- - -
- -
- - -
-
-
-
- ); -} - -export default CreateAttempt; diff --git a/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx b/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx deleted file mode 100644 index 9ec603da..00000000 --- a/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx +++ /dev/null @@ -1,337 +0,0 @@ -import { - ExternalLink, - GitFork, - History, - Play, - Plus, - ScrollText, - StopCircle, -} from 'lucide-react'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip.tsx'; -import { Button } from '@/components/ui/button.tsx'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu.tsx'; -import { useCallback, useMemo, useRef, useState, useEffect } from 'react'; -import type { TaskAttempt, TaskWithAttemptStatus } from 'shared/types'; -import { useBranchStatus, useOpenInEditor } from '@/hooks'; -import { useAttemptExecution } from '@/hooks/useAttemptExecution'; -import { useDevServer } from '@/hooks/useDevServer'; -import { useUserSystem } from '@/components/config-provider.tsx'; - -import { writeClipboardViaBridge } from '@/vscode/bridge'; -import { useProcessSelection } from '@/contexts/ProcessSelectionContext'; -import { openTaskForm } from '@/lib/openTaskForm'; - -// Helper function to get the display name for different editor types -function getEditorDisplayName(editorType: string): string { - switch (editorType) { - case 'VS_CODE': - return 'Visual Studio Code'; - case 'CURSOR': - return 'Cursor'; - case 'WINDSURF': - return 'Windsurf'; - case 'INTELLI_J': - return 'IntelliJ IDEA'; - case 'ZED': - return 'Zed'; - case 'XCODE': - return 'Xcode'; - case 'CUSTOM': - return 'Editor'; - default: - return 'Editor'; - } -} - -type Props = { - task: TaskWithAttemptStatus; - projectId: string; - projectHasDevScript: boolean; - selectedAttempt: TaskAttempt; - taskAttempts: TaskAttempt[]; - handleEnterCreateAttemptMode: () => void; - setSelectedAttempt: (attempt: TaskAttempt | null) => void; -}; - -function CurrentAttempt({ - task, - projectId, - projectHasDevScript, - selectedAttempt, - taskAttempts, - handleEnterCreateAttemptMode, - setSelectedAttempt, -}: Props) { - const { config } = useUserSystem(); - const { isAttemptRunning, stopExecution, isStopping } = useAttemptExecution( - selectedAttempt?.id, - task.id - ); - const { data: branchStatus, refetch: refetchBranchStatus } = useBranchStatus( - selectedAttempt?.id - ); - const hasConflicts = useMemo( - () => Boolean((branchStatus?.conflicted_files?.length ?? 0) > 0), - [branchStatus?.conflicted_files] - ); - const handleOpenInEditor = useOpenInEditor(selectedAttempt?.id); - const { jumpToProcess } = useProcessSelection(); - - // Attempt action hooks - const { - start: startDevServer, - stop: stopDevServer, - isStarting: isStartingDevServer, - runningDevServer, - latestDevServerProcess, - } = useDevServer(selectedAttempt?.id); - - const [copied, setCopied] = useState(false); - - const handleViewDevServerLogs = () => { - if (latestDevServerProcess) { - jumpToProcess(latestDevServerProcess.id); - } - }; - - const handleCreateSubtaskClick = () => { - openTaskForm({ - projectId, - initialBaseBranch: - selectedAttempt.branch || selectedAttempt.target_branch, - parentTaskAttemptId: selectedAttempt.id, - }); - }; - - // Use the stopExecution function from the hook - - const handleAttemptChange = useCallback( - (attempt: TaskAttempt) => { - setSelectedAttempt(attempt); - // React Query will handle refetching when attemptId changes - }, - [setSelectedAttempt] - ); - - // Refresh branch status when a process completes (e.g., rebase resolved by agent) - const prevRunningRef = useRef(isAttemptRunning); - useEffect(() => { - if (prevRunningRef.current && !isAttemptRunning && selectedAttempt?.id) { - refetchBranchStatus(); - } - prevRunningRef.current = isAttemptRunning; - }, [isAttemptRunning, selectedAttempt?.id, refetchBranchStatus]); - - // Get display name for the configured editor - const editorDisplayName = useMemo(() => { - if (!config?.editor?.editor_type) return 'Editor'; - return getEditorDisplayName(config.editor.editor_type); - }, [config?.editor?.editor_type]); - - const handleCopyWorktreePath = useCallback(async () => { - try { - await writeClipboardViaBridge(selectedAttempt.container_ref || ''); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy worktree path:', err); - } - }, [selectedAttempt.container_ref]); - - return ( -
- {/*
*/} -
-
-
- Agent -
-
{selectedAttempt.executor}
-
-
- -
-
-
- Path -
- -
-
- - {selectedAttempt.container_ref} - - {copied && ( - - Copied! - - )} -
-
- -
-
- {/* Column 1: Start Dev / View Logs */} -
- - - {/* View Dev Server Logs Button */} - {latestDevServerProcess && ( - - - - - - -

View dev server logs

-
-
-
- )} -
- - {/* Column 2: New Attempt + History (shared flex-1) */} -
- {isStopping || isAttemptRunning ? ( - - ) : ( - - )} - - {taskAttempts.length > 1 && !isStopping && !isAttemptRunning && ( - - - - - - - - - -

View attempt history

-
-
-
- - {taskAttempts.map((attempt) => ( - handleAttemptChange(attempt)} - className={ - selectedAttempt?.id === attempt.id ? 'bg-accent' : '' - } - > -
- - {new Date(attempt.created_at).toLocaleDateString()}{' '} - {new Date(attempt.created_at).toLocaleTimeString()} - - - {attempt.executor || 'Base Agent'} - -
-
- ))} -
-
- )} -
- - {/* Column 3: Create Subtask */} - -
-
-
- ); -} - -export default CurrentAttempt; diff --git a/frontend/src/components/tasks/Toolbar/GitOperations.tsx b/frontend/src/components/tasks/Toolbar/GitOperations.tsx index 25c7faa0..1875b190 100644 --- a/frontend/src/components/tasks/Toolbar/GitOperations.tsx +++ b/frontend/src/components/tasks/Toolbar/GitOperations.tsx @@ -9,7 +9,6 @@ import { ExternalLink, } from 'lucide-react'; import { Button } from '@/components/ui/button.tsx'; -import { Card } from '@/components/ui/card'; import { Tooltip, TooltipContent, @@ -44,6 +43,8 @@ interface GitOperationsProps { selectedBranch: string | null; } +export type GitOperationsInputs = Omit; + function GitOperations({ selectedAttempt, task, @@ -154,6 +155,17 @@ function GitOperations({ return t('git.states.rebase'); }, [rebasing, t]); + const prButtonLabel = useMemo(() => { + if (mergeInfo.hasOpenPR) { + return pushSuccess + ? t('git.states.pushed') + : pushing + ? t('git.states.pushing') + : t('git.states.push'); + } + return t('git.states.createPr'); + }, [mergeInfo.hasOpenPR, pushSuccess, pushing, t]); + const handleMergeClick = async () => { // Directly perform merge without checking branch status await performMerge(); @@ -257,190 +269,161 @@ function GitOperations({ } return ( -
- - Git - -
- {/* Branch Flow with Status Below */} -
- {/* Labels Row */} -
- {/* Task Branch Label - Left Column */} -
- +
+
+ {/* Left: Branch flow */} +
+ {/* Task branch chip */} + + + + + + {selectedAttempt.branch} + + + {t('git.labels.taskBranch')} - -
- {/* Center Column - Empty */} - {/* Target Branch Label - Right Column */} -
- - {t('rebase.dialog.targetLabel')} - -
-
- {/* Branches Row */} -
- {/* Task Branch - Left Column */} -
- - - {selectedAttempt.branch} - -
+ + + - {/* Arrow - Center Column */} -
- -
+ - {/* Target Branch - Right Column */} -
- - - {branchStatus?.target_branch_name || - selectedAttempt.target_branch || - selectedBranch || - t('git.branch.current')} - - - - - - - -

{t('branches.changeTarget.dialog.title')}

-
-
-
-
-
- - {/* Bottom Row: Status Information */} -
-
- {(() => { - const commitsAhead = branchStatus?.commits_ahead ?? 0; - const showAhead = commitsAhead > 0; - - if (showAhead) { - return ( - - {commitsAhead}{' '} - {t('git.status.commits', { count: commitsAhead })}{' '} - {t('git.status.ahead')} + {/* Target branch chip + change button */} +
+ + + + + + + {branchStatus?.target_branch_name || + selectedAttempt.target_branch || + selectedBranch || + t('git.branch.current')} - ); - } - return null; - })()} -
- -
- {(() => { - const commitsAhead = branchStatus?.commits_ahead ?? 0; - const commitsBehind = branchStatus?.commits_behind ?? 0; - const showAhead = commitsAhead > 0; - const showBehind = commitsBehind > 0; - - // Handle special states (PR, conflicts, etc.) - center under arrow - if (hasConflictsCalculated) { - return ( -
- - - {t('git.status.conflicts')} - -
- ); - } - - if (branchStatus?.is_rebase_in_progress) { - return ( -
- - - {t('git.states.rebasing')} - -
- ); - } - - // Check for merged PR - if (mergeInfo.hasMergedPR) { - return ( -
- - - {t('git.states.merged')} - -
- ); - } - - // Check for open PR - center under arrow - if (mergeInfo.hasOpenPR && mergeInfo.openPR?.type === 'pr') { - const prMerge = mergeInfo.openPR; - return ( - - ); - } - - // If showing ahead/behind, don't show anything in center - if (showAhead || showBehind) { - return null; - } - - // Default: up to date - center under arrow - return ( - - {t('git.status.upToDate')} - ); - })()} -
+ + + {t('rebase.dialog.targetLabel')} + + + -
- {(() => { - const commitsBehind = branchStatus?.commits_behind ?? 0; - const showBehind = commitsBehind > 0; - - if (showBehind) { - return ( - - {commitsBehind}{' '} - {t('git.status.commits', { count: commitsBehind })}{' '} - {t('git.status.behind')} - - ); - } - return null; - })()} -
+ + + + + + + {t('branches.changeTarget.dialog.title')} + + +
- {/* Git Operations - only show when branchStatus is available */} + {/* Center: Status chips */} +
+ {(() => { + const commitsAhead = branchStatus?.commits_ahead ?? 0; + const commitsBehind = branchStatus?.commits_behind ?? 0; + + if (hasConflictsCalculated) { + return ( + + + {t('git.status.conflicts')} + + ); + } + + if (branchStatus?.is_rebase_in_progress) { + return ( + + + {t('git.states.rebasing')} + + ); + } + + if (mergeInfo.hasMergedPR) { + return ( + + + {t('git.states.merged')} + + ); + } + + if (mergeInfo.hasOpenPR && mergeInfo.openPR?.type === 'pr') { + const prMerge = mergeInfo.openPR; + return ( + + ); + } + + const chips: React.ReactNode[] = []; + if (commitsAhead > 0) { + chips.push( + + +{commitsAhead}{' '} + {t('git.status.commits', { count: commitsAhead })}{' '} + {t('git.status.ahead')} + + ); + } + if (commitsBehind > 0) { + chips.push( + + {commitsBehind}{' '} + {t('git.status.commits', { count: commitsBehind })}{' '} + {t('git.status.behind')} + + ); + } + if (chips.length > 0) + return
{chips}
; + + return ( + + {t('git.status.upToDate')} + + ); + })()} +
+ + {/* Right: Actions (compact, right-aligned) */} {branchStatus && ( -
+
+ +
)} diff --git a/frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx b/frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx index d4135282..444f20ac 100644 --- a/frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx +++ b/frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx @@ -51,7 +51,7 @@ export function FollowUpEditorCard({ disabled={disabled} projectId={projectId} rows={1} - maxRows={6} + maxRows={30} onPasteFiles={onPasteFiles} /> {showLoadingOverlay && ( diff --git a/frontend/src/components/tasks/index.ts b/frontend/src/components/tasks/index.ts deleted file mode 100644 index 5d80032f..00000000 --- a/frontend/src/components/tasks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TaskCard } from './TaskCard'; diff --git a/frontend/src/components/ui/ActionsDropdown.tsx b/frontend/src/components/ui/ActionsDropdown.tsx new file mode 100644 index 00000000..a350b56a --- /dev/null +++ b/frontend/src/components/ui/ActionsDropdown.tsx @@ -0,0 +1,147 @@ +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { MoreHorizontal } from 'lucide-react'; +import type { TaskWithAttemptStatus, TaskAttempt } from 'shared/types'; +import { useOpenInEditor } from '@/hooks/useOpenInEditor'; +import NiceModal from '@ebay/nice-modal-react'; +import { useProject } from '@/contexts/project-context'; +import { openTaskForm } from '@/lib/openTaskForm'; + +interface ActionsDropdownProps { + task?: TaskWithAttemptStatus | null; + attempt?: TaskAttempt | null; +} + +export function ActionsDropdown({ task, attempt }: ActionsDropdownProps) { + const { t } = useTranslation('tasks'); + const { projectId } = useProject(); + const openInEditor = useOpenInEditor(attempt?.id); + + const hasAttemptActions = Boolean(attempt); + const hasTaskActions = Boolean(task); + + const handleEdit = () => { + if (!projectId || !task) return; + openTaskForm({ projectId, task }); + }; + + const handleDuplicate = () => { + if (!projectId || !task) return; + openTaskForm({ projectId, initialTask: task }); + }; + + const handleDelete = async () => { + if (!projectId || !task) return; + try { + await NiceModal.show('delete-task-confirmation', { + task, + projectId, + }); + } catch { + // User cancelled or error occurred + } + }; + + const handleOpenInEditor = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!attempt?.id) return; + openInEditor(); + }; + + const handleViewProcesses = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!attempt?.id) return; + NiceModal.show('view-processes', { attemptId: attempt.id }); + }; + + const handleCreateNewAttempt = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!task?.id) return; + NiceModal.show('create-attempt', { + taskId: task.id, + latestAttempt: null, + }); + }; + + const handleCreateSubtask = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!projectId || !attempt) return; + openTaskForm({ + projectId, + parentTaskAttemptId: attempt.id, + initialBaseBranch: attempt.branch || attempt.target_branch, + }); + }; + + return ( + <> + + + + + + {hasAttemptActions && ( + <> + {t('actionsMenu.attempt')} + + {t('actionsMenu.openInIde')} + + + {t('actionsMenu.viewProcesses')} + + + {t('actionsMenu.createNewAttempt')} + + + {t('actionsMenu.createSubtask')} + + + + )} + + {hasTaskActions && ( + <> + {t('actionsMenu.task')} + + {t('common:buttons.edit')} + + + {t('actionsMenu.duplicate')} + + + {t('common:buttons.delete')} + + + )} + + + + ); +} diff --git a/frontend/src/components/ui/TitleDescriptionEditor.tsx b/frontend/src/components/ui/TitleDescriptionEditor.tsx new file mode 100644 index 00000000..3790a7f7 --- /dev/null +++ b/frontend/src/components/ui/TitleDescriptionEditor.tsx @@ -0,0 +1,34 @@ +import { Input } from './input'; +import WYSIWYGEditor from './wysiwyg'; + +type Props = { + title: string; + description: string | null | undefined; + onTitleChange: (v: string) => void; + onDescriptionChange: (v: string) => void; +}; + +const TitleDescriptionEditor = ({ + title, + description, + onTitleChange, + onDescriptionChange, +}: Props) => { + return ( +
+ onTitleChange(e.target.value)} + /> + +
+ ); +}; + +export default TitleDescriptionEditor; diff --git a/frontend/src/components/ui/breadcrumb.tsx b/frontend/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..c95ad1a9 --- /dev/null +++ b/frontend/src/components/ui/breadcrumb.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { MoreHorizontal } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { cn } from '@/lib/utils'; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<'nav'> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) => +
+ - {/* Main Content */} -
- -
+ {/* Main Content */} +
+ +
+
); diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index e1012882..2d7f52f9 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -264,3 +264,29 @@ @apply underline; } } + +/* BASIC STYLES (mainly for rendering markdown) */ + +@layer base { + .wysiwyg { + h1 { + @apply text-lg leading-tight font-medium; + } + + h2 { + @apply leading-tight font-medium; + } + + ul { + @apply list-disc list-outside space-y-1 ps-6; + } + + ol { + @apply list-decimal list-outside space-y-1 ps-6; + } + + li { + @apply leading-tight; + } + } +} diff --git a/package.json b/package.json index 578791a9..d262086d 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,11 @@ "pnpm": ">=8" }, "dependencies": { - "lodash": "^4.17.21", "@dnd-kit/utilities": "^3.2.2", - "@ebay/nice-modal-react": "^1.2.13" + "@ebay/nice-modal-react": "^1.2.13", + "@radix-ui/react-toggle-group": "^1.1.11", + "framer-motion": "^12.23.22", + "lodash": "^4.17.21", + "react-resizable-panels": "^3.0.6" } } diff --git a/scripts/check-i18n.sh b/scripts/check-i18n.sh index 993fc152..f62a8cf8 100755 --- a/scripts/check-i18n.sh +++ b/scripts/check-i18n.sh @@ -44,6 +44,51 @@ get_json_keys() { ' "$file" 2>/dev/null | LC_ALL=C sort -u } +check_duplicate_keys() { + local file=$1 + if [ ! -f "$file" ]; then + return 2 + fi + + # Strategy: Use jq's --stream flag to detect duplicate keys + # jq --stream processes JSON before parsing (preserves duplicates) + # jq tostream processes JSON after parsing (duplicates already collapsed) + # If the outputs differ, duplicate keys exist + if ! diff -q <(jq --stream . "$file" 2>/dev/null) <(jq tostream "$file" 2>/dev/null) > /dev/null 2>&1; then + # Duplicates found + echo "duplicate keys detected" + return 1 + fi + return 0 +} + +check_duplicate_json_keys() { + local locales_dir="$REPO_ROOT/frontend/src/i18n/locales" + local exit_code=0 + + if [ ! -d "$locales_dir" ]; then + echo "❌ Locales directory not found: $locales_dir" + return 1 + fi + + # Check all JSON files in all locale directories + while IFS= read -r file; do + local rel_path="${file#$locales_dir/}" + local duplicates + + if duplicates=$(check_duplicate_keys "$file"); then + : # No duplicates found + else + echo "❌ [$rel_path] Duplicate keys found:" + printf ' - %s\n' $duplicates + echo " JSON silently overwrites duplicate keys - only the last occurrence is used!" + exit_code=1 + fi + done < <(find "$locales_dir" -type f -name "*.json" 2>/dev/null) + + return "$exit_code" +} + check_key_consistency() { local locales_dir="$REPO_ROOT/frontend/src/i18n/locales" local exit_code=0 @@ -175,6 +220,14 @@ else echo "✅ No new literal strings introduced." fi +echo "" +echo "▶️ Checking for duplicate JSON keys..." +if ! check_duplicate_json_keys; then + EXIT_STATUS=1 +else + echo "✅ No duplicate keys found in JSON files." +fi + echo "" echo "▶️ Checking translation key consistency..." if ! check_key_consistency; then