fix: show approve button wrongly + improve plan ux (#158)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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]">⌘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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user