diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index d6a2111b44..ec21d4844e 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -198,12 +198,8 @@ func (s *Service) handleWorkflowTransition(ctx context.Context, taskID, sessionI zap.String("current_step", currentStep.Name)) } - // Update session state to WAITING_FOR_INPUT - if err := s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateWaitingForInput, ""); err != nil { - s.logger.Error("failed to update session state to WAITING_FOR_INPUT", - zap.String("session_id", sessionID), - zap.Error(err)) - } + // Update session state to WAITING_FOR_INPUT and task state to REVIEW + s.setSessionWaitingForInput(ctx, taskID, sessionID) // Publish session updated event with review status if s.eventBus != nil { @@ -318,13 +314,9 @@ func (s *Service) handleWorkflowTransition(ctx context.Context, taskID, sessionI } } - // Update session state to WAITING_FOR_INPUT since the agent completed this step + // Update session state to WAITING_FOR_INPUT and task state to REVIEW since the agent completed this step // This must happen before publishing the event so the frontend gets the correct state - if err := s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateWaitingForInput, ""); err != nil { - s.logger.Error("failed to update session state to WAITING_FOR_INPUT during workflow transition", - zap.String("session_id", sessionID), - zap.Error(err)) - } + s.setSessionWaitingForInput(ctx, taskID, sessionID) // Publish session updated event with workflow step and review status changes // This allows the frontend to update the session state without a full refresh diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index a30ceb699f..702b245564 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -563,6 +563,12 @@ func (s *Service) resumeExecutorsOnStartup(ctx context.Context) { zap.String("session_id", sessionID), zap.Error(err)) } + // Also update task state to REVIEW since we're waiting for user input + if err := s.taskRepo.UpdateTaskState(ctx, running.TaskID, v1.TaskStateReview); err != nil { + s.logger.Warn("failed to set task state to REVIEW after resume", + zap.String("task_id", running.TaskID), + zap.Error(err)) + } } s.logger.Debug("session resumed successfully", diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index c727917a0e..3b7cec4de8 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -282,6 +282,32 @@ } } +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +@keyframes shimmer-vertical { + 0% { + transform: translateY(-100%); + } + 100% { + transform: translateY(100%); + } +} + +.animate-shimmer { + animation: shimmer 1.5s ease-in-out infinite; +} + +.animate-shimmer-vertical { + animation: shimmer-vertical 1.5s ease-in-out infinite; +} + @layer components { .border-flash-animation { position: relative; @@ -456,13 +482,23 @@ .milkdown-wrapper .milkdown { height: 100%; background: transparent; + display: flex; + flex-direction: column; +} + +.milkdown-wrapper .milkdown [data-milkdown-root] { + flex: 1; + display: flex; + flex-direction: column; } .milkdown-wrapper .editor { padding: 0.75rem; + flex: 1; min-height: 100%; outline: none; background: transparent; + cursor: text; } .milkdown-wrapper .editor:focus { @@ -646,3 +682,12 @@ padding: 0.5rem !important; white-space: pre-wrap !important; } + +/* Placeholder for empty editor - shows at cursor position like a real editor */ +.milkdown-wrapper .editor-placeholder { + color: var(--muted-foreground); + opacity: 0.5; + pointer-events: none; + font-size: 0.75rem; + user-select: none; +} diff --git a/apps/web/components/task/file-editor-content.tsx b/apps/web/components/task/file-editor-content.tsx index f558adda57..21ba64548c 100644 --- a/apps/web/components/task/file-editor-content.tsx +++ b/apps/web/components/task/file-editor-content.tsx @@ -8,7 +8,7 @@ import type { Extension } from '@codemirror/state'; import { getCodeMirrorExtensionFromPath } from '@/lib/languages'; import { Button } from '@kandev/ui/button'; import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons-react'; -import { formatDiffStats, calculateDiffStats } from '@/lib/utils/file-diff'; +import { formatDiffStats } from '@/lib/utils/file-diff'; type FileEditorContentProps = { path: string; diff --git a/apps/web/components/task/markdown-editor.tsx b/apps/web/components/task/markdown-editor.tsx index 3473b189be..f8bffa04ef 100644 --- a/apps/web/components/task/markdown-editor.tsx +++ b/apps/web/components/task/markdown-editor.tsx @@ -121,6 +121,35 @@ const commentHighlightPluginKey = new PluginKey<{ comments: CommentHighlight[]; }>('comment-highlight'); +// Plugin key for placeholder +const placeholderPluginKey = new PluginKey('placeholder'); + +// Create placeholder plugin that shows text when editor is empty +function createPlaceholderPlugin(placeholder: string) { + return $prose(() => { + return new Plugin({ + key: placeholderPluginKey, + props: { + decorations(state) { + const doc = state.doc; + // Check if document is empty (only has one empty paragraph) + if (doc.childCount === 1 && doc.firstChild?.type.name === 'paragraph' && doc.firstChild.content.size === 0) { + return DecorationSet.create(doc, [ + Decoration.widget(1, () => { + const span = document.createElement('span'); + span.className = 'editor-placeholder'; + span.textContent = placeholder; + return span; + }, { side: 0 }), + ]); + } + return DecorationSet.empty; + }, + }, + }); + }); +} + // Helper to get plain text content and position mapping from a document range function getTextWithPositions(doc: Parameters[0]): { text: string; positions: number[] } { let text = ''; @@ -318,6 +347,7 @@ function createCommentHighlightPlugin(initialComments: CommentHighlight[]) { function MilkdownEditorInner({ value, onChange, + placeholder = 'Start typing...', onSelectionChange, comments = [], onCommentClick, @@ -326,6 +356,7 @@ function MilkdownEditorInner({ const wrapperRef = useRef(null); // Capture initial value at mount - editor manages its own state after that const initialValueRef = useRef(value); + const placeholderRef = useRef(placeholder); const onChangeRef = useRef(onChange); const onSelectionChangeRef = useRef(onSelectionChange); const commentsRef = useRef(comments); @@ -396,17 +427,36 @@ function MilkdownEditorInner({ return; } - // Get position from selection range - const range = selection.getRangeAt(0); - const rect = range.getBoundingClientRect(); + // Get position from the focus point (end of selection where cursor is) + const focusNode = selection.focusNode; + const focusOffset = selection.focusOffset; - onSelectionChangeRef.current({ - text: selectedText, - position: { - x: rect.left + rect.width / 2, - y: rect.bottom, - }, - }); + if (focusNode) { + // Create a range at the focus point to get its position + const focusRange = document.createRange(); + focusRange.setStart(focusNode, focusOffset); + focusRange.setEnd(focusNode, focusOffset); + const focusRect = focusRange.getBoundingClientRect(); + + // If focusRect has no width (collapsed), use its position + // Otherwise fall back to the selection's last rect + let x = focusRect.right || focusRect.left; + let y = focusRect.bottom || focusRect.top; + + // If position is 0,0 (can happen), fall back to last rect of selection + if (x === 0 && y === 0) { + const range = selection.getRangeAt(0); + const rects = range.getClientRects(); + const lastRect = rects.length > 0 ? rects[rects.length - 1] : range.getBoundingClientRect(); + x = lastRect.right; + y = lastRect.bottom; + } + + onSelectionChangeRef.current({ + text: selectedText, + position: { x, y }, + }); + } } }; @@ -421,6 +471,12 @@ function MilkdownEditorInner({ [] ); + // Create the placeholder plugin once + const placeholderPlugin = useCallback( + () => createPlaceholderPlugin(placeholderRef.current), + [] + ); + const { loading, get } = useEditor((root) => Editor.make() .config((ctx) => { @@ -436,6 +492,7 @@ function MilkdownEditorInner({ .use(mermaidCodeBlockView) .use(listener) .use(highlightPlugin()) + .use(placeholderPlugin()) ); // Update decorations when comments change diff --git a/apps/web/components/task/plan-selection-popover.tsx b/apps/web/components/task/plan-selection-popover.tsx index 737bb1d841..61ad34e22d 100644 --- a/apps/web/components/task/plan-selection-popover.tsx +++ b/apps/web/components/task/plan-selection-popover.tsx @@ -86,13 +86,24 @@ export function PlanSelectionPopover({ ? selectedText.slice(0, 80).trim() + '…' : selectedText; - // Calculate position - flip above if would overflow bottom + // Calculate position - anchor at bottom-right of selection, adjust if would overflow + const popoverWidth = 384; // w-96 = 24rem = 384px const popoverHeight = 200; // approximate height of popover - const gap = 10; - const wouldOverflowBottom = position.y + gap + popoverHeight > window.innerHeight; - const top = wouldOverflowBottom - ? position.y - popoverHeight - gap // Position above selection - : position.y + gap; // Position below selection + const gap = 8; + + // Start at bottom-right of selection + let left = position.x + gap; + let top = position.y + gap; + + // If would overflow right, shift left + if (left + popoverWidth > window.innerWidth - 16) { + left = Math.max(16, window.innerWidth - popoverWidth - 16); + } + + // If would overflow bottom, position above the selection instead + if (top + popoverHeight > window.innerHeight - 16) { + top = Math.max(16, position.y - popoverHeight - gap); + } return (
{/* Text preview */} diff --git a/apps/web/components/task/task-center-panel.tsx b/apps/web/components/task/task-center-panel.tsx index 16d68d4651..718ae83875 100644 --- a/apps/web/components/task/task-center-panel.tsx +++ b/apps/web/components/task/task-center-panel.tsx @@ -15,7 +15,6 @@ import { SessionPanel } from '@kandev/ui/pannel-session'; import { TaskChatPanel } from './task-chat-panel'; import { TaskChangesPanel } from './task-changes-panel'; import { TaskPlanPanel } from './task-plan-panel'; -import { FileViewerContent } from './file-viewer-content'; import { FileEditorContent } from './file-editor-content'; import { PassthroughTerminal } from './passthrough-terminal'; import type { OpenFileTab, FileContentResponse } from '@/lib/types/backend'; @@ -25,7 +24,9 @@ import { SessionTabs, type SessionTab } from '@/components/session-tabs'; import { approveSessionAction } from '@/app/actions/workspaces'; import { getWebSocketClient } from '@/lib/ws/connection'; import { requestFileContent, updateFileContent } from '@/lib/ws/workspace-files'; -import { getPlanLastSeen, setPlanLastSeen } from '@/lib/local-storage'; +import { getPlanLastSeen, setPlanLastSeen, getCenterPanelTab, setCenterPanelTab } from '@/lib/local-storage'; +import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status'; +import { useSessionCommits } from '@/hooks/domains/session/use-session-commits'; import { generateUnifiedDiff, calculateHash } from '@/lib/utils/file-diff'; import { useToast } from '@/components/toast-provider'; @@ -67,8 +68,11 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ // Check if we should show the approve button // Show when session has a review_status that is not approved (meaning it's in a review step) // Also hide while agent is working to prevent premature approval + // Use truthy check to handle null, undefined, and empty string (backend may send "" when clearing) const showApproveButton = - activeSession?.review_status != null && activeSession.review_status !== 'approved' && !isAgentWorking; + !!activeSession?.review_status && + activeSession.review_status !== 'approved' && + !isAgentWorking; // Approve handler - moves session to next workflow step const handleApprove = useCallback(async () => { @@ -101,7 +105,28 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ } }, [activeSessionId, activeTaskId, setTaskSession]); - const [leftTab, setLeftTab] = useState('chat'); + // Get git status and commits to determine if there are changes + const gitStatus = useSessionGitStatus(activeSessionId); + const { commits } = useSessionCommits(activeSessionId); + const hasChanges = useMemo(() => { + const hasUncommittedChanges = gitStatus?.files && Object.keys(gitStatus.files).length > 0; + const hasCommits = commits && commits.length > 0; + return hasUncommittedChanges || hasCommits; + }, [gitStatus, commits]); + + // Initialize tab from localStorage, defaulting to 'chat' + // If saved tab is 'changes' but there are no changes, fall back to 'chat' + const [leftTab, setLeftTab] = useState(() => { + const savedTab = getCenterPanelTab('chat'); + return savedTab; + }); + + // If current tab is 'changes' but there are no changes, switch to 'chat' + useEffect(() => { + if (leftTab === 'changes' && !hasChanges) { + setLeftTab('chat'); + } + }, [leftTab, hasChanges]); // Request changes state - triggers focus and tooltip on chat input const [showRequestChangesTooltip, setShowRequestChangesTooltip] = useState(false); @@ -140,6 +165,10 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ setPlanLastSeen(activeTaskId, plan.updated_at); } setLeftTab(tab); + // Persist tab selection (only for main tabs, not file tabs) + if (tab === 'plan' || tab === 'changes' || tab === 'chat') { + setCenterPanelTab(tab); + } }, [activeTaskId, plan]); // Handle external diff selection @@ -287,8 +316,6 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ const tabs: SessionTab[] = useMemo(() => { const staticTabs: SessionTab[] = [ - { id: 'changes', label: 'All changes' }, - { id: 'chat', label: 'Chat' }, { id: 'plan', label: 'Plan', @@ -299,6 +326,9 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
) : undefined, }, + // Only show "All changes" tab if there are changes + ...(hasChanges ? [{ id: 'changes', label: 'All changes' }] : []), + { id: 'chat', label: 'Chat' }, ]; const fileTabs: SessionTab[] = openFileTabs.map((tab) => { @@ -318,7 +348,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ }); return [...staticTabs, ...fileTabs]; - }, [openFileTabs, handleCloseFileTab, hasUnseenPlanUpdate]); + }, [openFileTabs, handleCloseFileTab, hasUnseenPlanUpdate, hasChanges]); return ( @@ -326,7 +356,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ tabs={tabs} activeTab={leftTab} onTabChange={handleTabChange} - separatorAfterIndex={openFileTabs.length > 0 ? 2 : undefined} + separatorAfterIndex={openFileTabs.length > 0 ? (hasChanges ? 2 : 1) : undefined} className="flex-1 min-h-0 flex flex-col gap-2" rightContent={ showApproveButton ? ( diff --git a/apps/web/components/task/task-plan-panel.tsx b/apps/web/components/task/task-plan-panel.tsx index 1696e02efc..85b9e0f195 100644 --- a/apps/web/components/task/task-plan-panel.tsx +++ b/apps/web/components/task/task-plan-panel.tsx @@ -2,7 +2,8 @@ import { memo, useState, useCallback, useEffect, useRef } from 'react'; import dynamic from 'next/dynamic'; -import { IconLoader2, IconEdit } from '@tabler/icons-react'; +import { IconLoader2, IconFileText, IconRobot, IconMessage, IconClick } from '@tabler/icons-react'; +import { cn } from '@/lib/utils'; import { useTaskPlan } from '@/hooks/domains/session/use-task-plan'; import { useAppStore } from '@/components/state-provider'; import { getWebSocketClient } from '@/lib/ws/connection'; @@ -47,16 +48,35 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru const [isSubmittingComments, setIsSubmittingComments] = useState(false); // Ref to the editor wrapper for positioning gutter markers const editorWrapperRef = useRef(null); + // Track editor focus state to show/hide placeholder + const [isEditorFocused, setIsEditorFocused] = useState(false); // Handler to focus the editor when clicking the empty state const handleEmptyStateClick = useCallback(() => { - // Find the ProseMirror editor element and focus it const editorElement = editorWrapperRef.current?.querySelector('.ProseMirror'); if (editorElement) { (editorElement as HTMLElement).focus(); } }, []); + // Track focus state using document-level listener + useEffect(() => { + const checkFocus = () => { + const wrapper = editorWrapperRef.current; + if (!wrapper) return; + setIsEditorFocused(wrapper.contains(document.activeElement)); + }; + + document.addEventListener('focusin', checkFocus); + document.addEventListener('focusout', checkFocus); + checkFocus(); + + return () => { + document.removeEventListener('focusin', checkFocus); + document.removeEventListener('focusout', checkFocus); + }; + }, []); + // Sync draft with plan content and force editor remount when plan changes externally useEffect(() => { const prevContent = lastPlanContentRef.current; @@ -256,7 +276,16 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru return (
{/* Content - Markdown Editor with inline comment highlights */} -
+
- {/* Empty state overlay - clickable to focus editor */} - {!isLoading && draftContent.trim() === '' && ( + {/* Rich empty state - shows when no content and editor not focused */} + {!isLoading && draftContent.trim() === '' && !isEditorFocused && (
-
- - Start writing your plan here +
+ {/* Main icon */} +
+ +
+ + {/* Title */} +
+

Plan your implementation

+

+ A shared document for you and the agent to collaborate on the approach +

+
+ + {/* Feature list */} +
+
+ +

+ The agent can write and update the plan as it works +

+
+
+ +

+ Select text and press ⌘I to request changes +

+
+
+ + {/* Call to action */} +
+ + Click anywhere to start writing +
)} diff --git a/apps/web/components/task/task-plan-toolbar.tsx b/apps/web/components/task/task-plan-toolbar.tsx index eec9f9c9df..8f01855b12 100644 --- a/apps/web/components/task/task-plan-toolbar.tsx +++ b/apps/web/components/task/task-plan-toolbar.tsx @@ -12,6 +12,7 @@ import { IconTrash, IconSend, } from '@tabler/icons-react'; +import { GridSpinner } from '@/components/grid-spinner'; import { Button } from '@kandev/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { cn, formatRelativeTime } from '@/lib/utils'; @@ -61,10 +62,16 @@ export const TaskPlanToolbar = memo(function TaskPlanToolbar({
+ {/* Animated progress bar when agent is working */} + {isAgentBusy && ( +
+
+
+ )}
{/* Left: Status info */}
@@ -83,7 +90,12 @@ export const TaskPlanToolbar = memo(function TaskPlanToolbar({ Created by {plan.created_by} )} - {hasUnsavedChanges ? ( + {isAgentBusy ? ( + + Agent is working + + + ) : hasUnsavedChanges ? ( Unsaved changes · ⌘S to save diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index 67802d97ea..3ffdc1c066 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -117,3 +117,35 @@ export function getPlanLastSeen(taskId: string): string | null { const state = getPlanNotificationState(); return state[taskId] ?? null; } + +// Internal storage key for center panel tab (uses sessionStorage) +const CENTER_PANEL_TAB_KEY = 'kandev.centerPanel.tab'; + +/** + * Get the saved center panel tab from sessionStorage + * @param fallback - Default tab if not found + * @returns The saved tab id + */ +export function getCenterPanelTab(fallback: string): string { + if (typeof window === 'undefined') return fallback; + try { + const raw = window.sessionStorage.getItem(CENTER_PANEL_TAB_KEY); + if (!raw) return fallback; + return JSON.parse(raw) as string; + } catch { + return fallback; + } +} + +/** + * Save the center panel tab to sessionStorage + * @param tab - The tab id to save + */ +export function setCenterPanelTab(tab: string): void { + if (typeof window === 'undefined') return; + try { + window.sessionStorage.setItem(CENTER_PANEL_TAB_KEY, JSON.stringify(tab)); + } catch { + // Ignore write failures + } +}