fix: show approve button wrongly + improve plan ux (#158)

This commit is contained in:
Carlos Florêncio
2026-02-02 22:45:35 +00:00
committed by GitHub
parent 14a7601360
commit ed6c6e8d1b
10 changed files with 296 additions and 50 deletions
@@ -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
@@ -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",
+45
View File
@@ -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;
}
@@ -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;
+67 -10
View File
@@ -121,6 +121,35 @@ const commentHighlightPluginKey = new PluginKey<{
comments: CommentHighlight[];
}>('comment-highlight');
// Plugin key for placeholder
const placeholderPluginKey = new PluginKey<DecorationSet>('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<typeof DecorationSet.create>[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<HTMLDivElement>(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
@@ -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 (
<div
@@ -102,8 +113,8 @@ export function PlanSelectionPopover({
'animate-in fade-in-0 zoom-in-95 duration-150'
)}
style={{
left: Math.min(position.x - 192, window.innerWidth - 400),
top: Math.max(10, top), // Ensure at least 10px from top
left,
top,
}}
>
{/* Text preview */}
+38 -8
View File
@@ -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({
</div>
) : 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 (
<SessionPanel borderSide="right" margin="right">
@@ -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 ? (
+70 -9
View File
@@ -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<HTMLDivElement>(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 (
<div className="flex flex-col h-full">
{/* Content - Markdown Editor with inline comment highlights */}
<div className="flex-1 min-h-0 relative" ref={editorWrapperRef}>
<div
className={cn(
"flex-1 min-h-0 relative rounded-lg border transition-colors cursor-text",
isEditorFocused
? "border-primary/50"
: "border-transparent"
)}
ref={editorWrapperRef}
onClick={handleEmptyStateClick}
>
<MarkdownEditor
key={`${taskId}-${editorKey}`}
value={draftContent}
@@ -266,15 +295,47 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru
comments={commentHighlights}
onCommentClick={handleCommentHighlightClick}
/>
{/* 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 && (
<div
className="absolute inset-0 flex items-center justify-center cursor-text bg-background"
className="absolute inset-0 flex items-center justify-center pointer-events-none"
onClick={handleEmptyStateClick}
>
<div className="flex flex-col items-center gap-2 text-muted-foreground pointer-events-none">
<IconEdit className="h-8 w-8 opacity-50" />
<span className="text-sm">Start writing your plan here</span>
<div className="flex flex-col items-center gap-6 max-w-md px-6">
{/* Main icon */}
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-muted/50">
<IconFileText className="h-6 w-6 text-muted-foreground" />
</div>
{/* Title */}
<div className="text-center">
<h3 className="text-sm font-medium text-foreground mb-1">Plan your implementation</h3>
<p className="text-xs text-muted-foreground">
A shared document for you and the agent to collaborate on the approach
</p>
</div>
{/* Feature list */}
<div className="flex flex-col gap-3 w-full">
<div className="flex items-start gap-3">
<IconRobot className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-xs text-muted-foreground">
The agent can write and update the plan as it works
</p>
</div>
<div className="flex items-start gap-3">
<IconMessage className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-xs text-muted-foreground">
Select text and press <kbd className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-mono text-[10px]">&#8984;I</kbd> to request changes
</p>
</div>
</div>
{/* Call to action */}
<div className="flex items-center gap-2 text-xs text-muted-foreground/70">
<IconClick className="h-3.5 w-3.5" />
<span>Click anywhere to start writing</span>
</div>
</div>
</div>
)}
+14 -2
View File
@@ -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({
<div className="mt-2">
<div
className={cn(
'rounded-2xl border border-border bg-background shadow-sm overflow-hidden',
'rounded-2xl border border-border bg-background shadow-sm overflow-hidden relative',
hasUnsavedChanges && 'border-amber-500/50 border-dashed'
)}
>
{/* Animated progress bar when agent is working */}
{isAgentBusy && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 overflow-hidden">
<div className="h-full w-full bg-gradient-to-r from-transparent via-primary/60 to-transparent animate-shimmer" />
</div>
)}
<div className="flex items-center gap-1 px-2 py-2">
{/* Left: Status info */}
<div className="flex items-center gap-1 flex-1">
@@ -83,7 +90,12 @@ export const TaskPlanToolbar = memo(function TaskPlanToolbar({
<TooltipContent>Created by {plan.created_by}</TooltipContent>
</Tooltip>
)}
{hasUnsavedChanges ? (
{isAgentBusy ? (
<span className="flex items-center gap-2 text-xs text-muted-foreground px-2">
<span>Agent is working</span>
<GridSpinner className="h-3 w-3" />
</span>
) : hasUnsavedChanges ? (
<span className="text-xs text-amber-500 px-2">
Unsaved changes <span className="text-amber-500/60">· ⌘S to save</span>
</span>
+32
View File
@@ -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
}
}