feat: plan comment annotations, Kandev system prompt, and Standard workflow (#134)

This commit is contained in:
João Salavisa
2026-01-31 23:57:56 +00:00
committed by GitHub
parent 6d23d68633
commit eedbfdf68a
14 changed files with 1676 additions and 60 deletions
+8
View File
@@ -6,6 +6,7 @@ import (
agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller"
"github.com/kandev/kandev/internal/common/logger"
userservice "github.com/kandev/kandev/internal/user/service"
"go.uber.org/zap"
)
func runInitialAgentSetup(
@@ -20,6 +21,13 @@ func runInitialAgentSetup(
return err
}
// Ensure all agent profiles that support MCP have MCP enabled by default
// This is idempotent - it only creates MCP configs for profiles that don't have one
if err := agentSettingsController.EnsureDefaultMcpConfig(ctx); err != nil {
log.Warn("Failed to ensure default MCP config", zap.Error(err))
// Continue anyway - MCP config is not critical for startup
}
// Mark initial setup as complete if not already
settings, err := userSvc.GetUserSettings(ctx)
if err != nil {
@@ -182,13 +182,19 @@ func (sm *SessionManager) getResumeContextPrompt(agentConfig *registry.AgentType
return resumePrompt
}
// injectSessionContext prepends session context information to the prompt.
// This allows the agent to know which session ID and task ID to use when calling MCP tools.
func (sm *SessionManager) injectSessionContext(taskID, sessionID, prompt string) string {
if sessionID == "" && taskID == "" {
return prompt
}
return fmt.Sprintf("<session_context>\nKandev Task ID: %s\nKandev Session ID: %s\nUse these IDs when calling Kandev MCP tools that require task_id or session_id parameters.\n</session_context>\n\n%s", taskID, sessionID, prompt)
// injectKandevContext prepends the Kandev system prompt and session context to the user's prompt.
// This provides consistent guidance to the agent about using Kandev tools and the current session IDs.
func (sm *SessionManager) injectKandevContext(taskID, sessionID, prompt string) string {
return fmt.Sprintf(`<kandev-system>
IMPORTANT KANDEV INSTRUCTIONS:
- When you have questions for the user, use the ask_user_question_kandev MCP tool to ask them directly.
- When you need to create or update a plan for a task, use the Kandev MCP plan tools (plan_get, plan_update, etc.).
- Kandev Task ID: %s
- Kandev Session ID: %s
- Always use these IDs when calling Kandev MCP tools that require task_id or session_id parameters.
</kandev-system>
%s`, taskID, sessionID, prompt)
}
// loadSession loads an existing session via ACP session/load
@@ -318,11 +324,11 @@ func (sm *SessionManager) InitializeAndPrompt(
// For resumed sessions (fork_session pattern): If we have session history but no new task description,
// we still need to send a resume context prompt so the agent knows about the prior conversation.
if taskDescription != "" {
// Inject task and session ID context so the agent knows which IDs to use for MCP tools
promptWithSessionContext := sm.injectSessionContext(execution.TaskID, execution.SessionID, taskDescription)
// Inject Kandev system prompt and session context
promptWithContext := sm.injectKandevContext(execution.TaskID, execution.SessionID, taskDescription)
// Check if we need to inject resume context (fork_session pattern for ACP agents)
effectivePrompt := sm.getResumeContextPrompt(agentConfig, execution.SessionID, promptWithSessionContext)
effectivePrompt := sm.getResumeContextPrompt(agentConfig, execution.SessionID, promptWithContext)
if effectivePrompt != taskDescription {
sm.logger.Info("injecting resume context into initial prompt",
zap.String("execution_id", execution.ID),
@@ -405,17 +411,20 @@ func (sm *SessionManager) SendPrompt(
// Check if we need to inject resume context (fork_session pattern)
// This happens on the first user prompt after resuming a session
// For resumed sessions, we also inject the Kandev context since it's the first prompt in this execution
effectivePrompt := prompt
if execution.needsResumeContext && !execution.resumeContextInjected {
// Inject Kandev context for resumed sessions (first prompt in this execution)
promptWithContext := sm.injectKandevContext(execution.TaskID, execution.SessionID, prompt)
if sm.historyManager != nil {
resumePrompt, err := sm.historyManager.GenerateResumeContext(execution.SessionID, prompt)
resumePrompt, err := sm.historyManager.GenerateResumeContext(execution.SessionID, promptWithContext)
if err != nil {
sm.logger.Warn("failed to generate resume context for follow-up prompt",
zap.String("execution_id", execution.ID),
zap.Error(err))
} else if resumePrompt != prompt {
effectivePrompt = promptWithContext
} else if resumePrompt != promptWithContext {
effectivePrompt = resumePrompt
execution.resumeContextInjected = true
sm.logger.Info("injecting resume context into follow-up prompt",
zap.String("execution_id", execution.ID),
zap.String("session_id", execution.SessionID),
@@ -425,8 +434,13 @@ func (sm *SessionManager) SendPrompt(
sm.logger.Info("resume context prompt content",
zap.String("execution_id", execution.ID),
zap.String("resume_prompt", effectivePrompt))
} else {
effectivePrompt = promptWithContext
}
} else {
effectivePrompt = promptWithContext
}
execution.resumeContextInjected = true
}
sm.logger.Info("sending prompt to agent",
@@ -142,6 +142,7 @@ func (r *Repository) runMigrations() error {
}
// seedDefaultWorkflowSteps creates default workflow steps for boards that don't have any.
// Uses the standard template as the default workflow.
func (r *Repository) seedDefaultWorkflowSteps() error {
// Find boards without workflow steps
rows, err := r.db.Query(`
@@ -166,27 +167,41 @@ func (r *Repository) seedDefaultWorkflowSteps() error {
return err
}
// Create default workflow steps for each board
// Use the standard template for default workflow steps
now := time.Now()
type stepSeed struct {
name string
position int
stepType string
color string
}
defaultSteps := []stepSeed{
{name: "Todo", position: 0, stepType: "backlog", color: "bg-neutral-400"},
{name: "In Progress", position: 1, stepType: "implementation", color: "bg-blue-500"},
{name: "Review", position: 2, stepType: "review", color: "bg-yellow-500"},
{name: "Done", position: 3, stepType: "done", color: "bg-green-500"},
}
standardTemplate := r.getStandardTemplate(now)
for _, boardID := range boardIDs {
for _, step := range defaultSteps {
// Map template step IDs to generated UUIDs for linking
idMap := make(map[string]string)
for _, stepDef := range standardTemplate.Steps {
idMap[stepDef.ID] = uuid.New().String()
}
for _, stepDef := range standardTemplate.Steps {
stepID := idMap[stepDef.ID]
// Map OnCompleteStepID if set
var onCompleteStepID *string
if stepDef.OnCompleteStepID != "" {
if mappedID, ok := idMap[stepDef.OnCompleteStepID]; ok {
onCompleteStepID = &mappedID
}
}
if _, err := r.db.Exec(`
INSERT INTO workflow_steps (id, board_id, name, step_type, position, color, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, uuid.New().String(), boardID, step.name, step.stepType, step.position, step.color, now, now); err != nil {
INSERT INTO workflow_steps (
id, board_id, name, step_type, position, color,
auto_start_agent, plan_mode, require_approval,
prompt_prefix, prompt_suffix, on_complete_step_id,
allow_manual_move, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
stepID, boardID, stepDef.Name, string(stepDef.StepType), stepDef.Position, stepDef.Color,
stepDef.AutoStartAgent, stepDef.PlanMode, stepDef.RequireApproval,
stepDef.PromptPrefix, stepDef.PromptSuffix, onCompleteStepID,
stepDef.AllowManualMove, now, now,
); err != nil {
return err
}
}
@@ -231,12 +246,75 @@ func (r *Repository) seedSystemTemplates() error {
func (r *Repository) getSystemTemplates() []*models.WorkflowTemplate {
now := time.Now().UTC()
return []*models.WorkflowTemplate{
r.getStandardTemplate(now),
r.getDevTemplate(now),
r.getArchitectureTemplate(now),
r.getSimpleTemplate(now),
}
}
// getStandardTemplate returns the standard 4-step workflow template (default).
// Steps: Todo → Plan → Implementation → Done
func (r *Repository) getStandardTemplate(now time.Time) *models.WorkflowTemplate {
return &models.WorkflowTemplate{
ID: "standard",
Name: "Standard",
Description: "Standard workflow with planning and implementation phases",
IsSystem: true,
CreatedAt: now,
UpdatedAt: now,
Steps: []models.StepDefinition{
{
ID: "todo",
Name: "Todo",
StepType: models.StepTypeBacklog,
Position: 0,
Color: "bg-neutral-400",
TaskState: "TODO",
AutoStartAgent: false,
AllowManualMove: true,
},
{
ID: "plan",
Name: "Plan",
StepType: models.StepTypePlanning,
Position: 1,
Color: "bg-purple-500",
TaskState: "IN_PROGRESS",
AutoStartAgent: true,
PlanMode: true,
RequireApproval: true,
PromptPrefix: "[PLANNING PHASE]\nAnalyze this task and create a detailed implementation plan.\nDo NOT make any code changes yet - only analyze and plan.\n\nBefore creating the plan, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nCreate a plan that includes:\n1. Understanding of the requirements\n2. Files that need to be modified or created\n3. Step-by-step implementation approach\n4. Potential risks or considerations\n\nWhen including diagrams in your plan (architecture, sequence, flowcharts, etc.), always use mermaid syntax in code blocks.\n\nIMPORTANT: Save your plan using the create_task_plan_kandev MCP tool with the task_id provided in the session context.\nAfter saving the plan, STOP and wait for user review. The user will review your plan in the UI and may edit it.\nDo not create any other files during this phase - only use the MCP tool to save the plan.",
OnCompleteStepID: "implementation",
AllowManualMove: true,
},
{
ID: "implementation",
Name: "Implementation",
StepType: models.StepTypeImplementation,
Position: 2,
Color: "bg-blue-500",
TaskState: "IN_PROGRESS",
AutoStartAgent: true,
RequireApproval: true,
PromptPrefix: "[IMPLEMENTATION PHASE]\nBefore starting implementation, retrieve the task plan using get_task_plan_kandev with the task_id from the session context.\nReview the plan carefully, including any edits the user may have made.\nAcknowledge the plan and any user modifications before proceeding.\n\nThen implement the task following the plan step-by-step.\nYou can update the plan during implementation using update_task_plan_kandev if needed.",
OnCompleteStepID: "done",
AllowManualMove: true,
},
{
ID: "done",
Name: "Done",
StepType: models.StepTypeDone,
Position: 3,
Color: "bg-green-500",
TaskState: "COMPLETED",
AutoStartAgent: false,
AllowManualMove: true,
},
},
}
}
// getDevTemplate returns the full development workflow template.
func (r *Repository) getDevTemplate(now time.Time) *models.WorkflowTemplate {
return &models.WorkflowTemplate{
@@ -266,7 +344,7 @@ func (r *Repository) getDevTemplate(now time.Time) *models.WorkflowTemplate {
TaskState: "IN_PROGRESS",
AutoStartAgent: true,
PlanMode: true,
PromptPrefix: "[PLANNING PHASE]\nAnalyze this task and create a detailed implementation plan.\nDo NOT make any code changes yet - only analyze and plan.\n\nBefore creating the plan, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nCreate a plan that includes:\n1. Understanding of the requirements\n2. Files that need to be modified or created\n3. Step-by-step implementation approach\n4. Potential risks or considerations\n\nIMPORTANT: Save your plan using the create_task_plan_kandev MCP tool with the task_id provided in the session context.\nAfter saving the plan, STOP and wait for user review. The user will review your plan in the UI and may edit it.\nDo not create any other files during this phase - only use the MCP tool to save the plan.",
PromptPrefix: "[PLANNING PHASE]\nAnalyze this task and create a detailed implementation plan.\nDo NOT make any code changes yet - only analyze and plan.\n\nBefore creating the plan, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nCreate a plan that includes:\n1. Understanding of the requirements\n2. Files that need to be modified or created\n3. Step-by-step implementation approach\n4. Potential risks or considerations\n\nWhen including diagrams in your plan (architecture, sequence, flowcharts, etc.), always use mermaid syntax in code blocks.\n\nIMPORTANT: Save your plan using the create_task_plan_kandev MCP tool with the task_id provided in the session context.\nAfter saving the plan, STOP and wait for user review. The user will review your plan in the UI and may edit it.\nDo not create any other files during this phase - only use the MCP tool to save the plan.",
OnCompleteStepID: "review-plan",
AllowManualMove: true,
},
@@ -349,7 +427,7 @@ func (r *Repository) getArchitectureTemplate(now time.Time) *models.WorkflowTemp
TaskState: "IN_PROGRESS",
AutoStartAgent: true,
PlanMode: true,
PromptPrefix: "[ARCHITECTURE PHASE]\nAnalyze and design the architecture for this task.\n\nBefore creating the design, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nIMPORTANT: Save your design using the create_task_plan_kandev MCP tool with the task_id provided in the session context.\nAfter saving the plan, STOP and wait for user review. The user will review your design in the UI and may edit it.\nDo not create any other files during this phase - only use the MCP tool to save the plan.",
PromptPrefix: "[ARCHITECTURE PHASE]\nAnalyze and design the architecture for this task.\n\nBefore creating the design, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nWhen including diagrams in your design (architecture, sequence, flowcharts, etc.), always use mermaid syntax in code blocks.\n\nIMPORTANT: Save your design using the create_task_plan_kandev MCP tool with the task_id provided in the session context.\nAfter saving the plan, STOP and wait for user review. The user will review your design in the UI and may edit it.\nDo not create any other files during this phase - only use the MCP tool to save the plan.",
OnCompleteStepID: "review",
AllowManualMove: true,
},
+720
View File
File diff suppressed because it is too large Load Diff
+35 -1
View File
@@ -485,7 +485,7 @@
/* Base text styling - match chat text-xs */
.milkdown-wrapper .editor,
.milkdown-wrapper .editor * {
.milkdown-wrapper .editor *:not(.mermaid-container):not(.mermaid-container *) {
font-size: 0.75rem !important;
line-height: 1.625;
color: var(--foreground);
@@ -612,3 +612,37 @@
border-top: 1px solid var(--border) !important;
margin: 1rem 0 !important;
}
/* Comment highlights in plan editor - inline */
.milkdown-wrapper .comment-highlight {
background-color: rgba(59, 130, 246, 0.45);
border-radius: 3px;
cursor: pointer;
transition: background-color 0.15s ease;
padding: 2px 1px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.milkdown-wrapper .comment-highlight:hover {
background-color: rgba(59, 130, 246, 0.6);
}
/* Mermaid diagram styles */
.milkdown-wrapper .mermaid-container {
margin: 0.75rem 0 !important;
padding: 0.5rem !important;
overflow-x: auto !important;
}
.milkdown-wrapper .mermaid-container svg {
max-width: 100% !important;
height: auto !important;
}
.milkdown-wrapper .mermaid-error {
color: #ef4444 !important;
font-size: 0.75rem !important;
padding: 0.5rem !important;
white-space: pre-wrap !important;
}
@@ -111,7 +111,7 @@ export const ChatMessage = memo(function ChatMessage({ comment, label, className
);
}
// User message: right-aligned bubble
// User message: right-aligned bubble with markdown support
if (isUser) {
return (
<div className="flex justify-end w-full overflow-hidden">
@@ -68,19 +68,24 @@ export function ClarificationRequestMessage({ comment }: ClarificationRequestMes
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header with question and answer */}
<div className="flex items-center gap-2 text-xs">
<span className="font-medium text-muted-foreground">
{question.prompt}
</span>
{getStatusIndicator()}
{isAnswered && (
<span className="text-foreground/80">{getAnswerSummary()}</span>
)}
{isSkipped && (
<span className="text-muted-foreground">Skipped</span>
)}
{/* Question */}
<div className="text-xs font-medium text-muted-foreground">
{question.prompt}
</div>
{/* Answer - indented below question */}
{isAnswered && (
<div className="mt-1 ml-3 flex items-center gap-1.5 text-xs text-foreground/80">
{getStatusIndicator()}
{getAnswerSummary()}
</div>
)}
{isSkipped && (
<div className="mt-1 ml-3 flex items-center gap-1.5 text-xs text-muted-foreground">
{getStatusIndicator()}
Skipped
</div>
)}
</div>
</div>
</div>
+430 -7
View File
@@ -1,32 +1,427 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useTheme } from 'next-themes';
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/core';
import { Editor, rootCtx, defaultValueCtx, editorViewCtx } from '@milkdown/core';
import { nord } from '@milkdown/theme-nord';
import { commonmark } from '@milkdown/preset-commonmark';
import { commonmark, codeBlockSchema } from '@milkdown/preset-commonmark';
import { gfm } from '@milkdown/preset-gfm';
import { listener, listenerCtx } from '@milkdown/plugin-listener';
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
import { Plugin, PluginKey } from '@milkdown/prose/state';
import { Decoration, DecorationSet } from '@milkdown/prose/view';
import { $prose, $view } from '@milkdown/utils';
import type { Node as ProseMirrorNode } from '@milkdown/prose/model';
import mermaid from 'mermaid';
export type TextSelection = {
text: string;
position: { x: number; y: number };
};
export type CommentHighlight = {
id: string;
selectedText: string;
comment: string;
};
type MilkdownEditorProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
onSelectionChange?: (selection: TextSelection | null) => void;
comments?: CommentHighlight[];
onCommentClick?: (id: string, position: { x: number; y: number }) => void;
};
function MilkdownEditorInner({ value, onChange }: MilkdownEditorProps) {
// Minimum text length required for comment highlighting
const MIN_COMMENT_TEXT_LENGTH = 3;
// Initialize mermaid with default config
let mermaidInitialized = false;
function initMermaid(theme: 'dark' | 'light' = 'dark') {
mermaid.initialize({
startOnLoad: false,
theme: theme === 'dark' ? 'dark' : 'default',
securityLevel: 'loose',
});
mermaidInitialized = true;
}
// Unique ID counter for mermaid diagrams
let mermaidIdCounter = 0;
// Create mermaid node view for code blocks using $view
const mermaidCodeBlockView = $view(codeBlockSchema.node, () => {
return (node: ProseMirrorNode) => {
const language = node.attrs.language as string;
// If not mermaid, return null to use default rendering
if (language !== 'mermaid') {
// Create default code block rendering
const pre = document.createElement('pre');
const code = document.createElement('code');
code.textContent = node.textContent;
if (language) {
code.className = `language-${language}`;
}
pre.appendChild(code);
return {
dom: pre,
contentDOM: code,
};
}
// Track current content for comparison in update
let currentContent = node.textContent;
// Create container for mermaid diagram
const dom = document.createElement('div');
dom.className = 'mermaid-container';
// Initialize mermaid if not already done
if (!mermaidInitialized) {
initMermaid();
}
// Render the mermaid diagram
const id = `mermaid-${++mermaidIdCounter}`;
mermaid.render(id, currentContent).then(({ svg }) => {
dom.innerHTML = svg;
}).catch((err) => {
dom.innerHTML = `<pre class="mermaid-error">Error rendering diagram: ${err.message}</pre>`;
});
return {
dom,
contentDOM: null,
update: (updatedNode: ProseMirrorNode) => {
if (updatedNode.type.name !== 'code_block') return false;
const updatedLang = updatedNode.attrs.language as string;
if (updatedLang !== 'mermaid') return false;
const newContent = updatedNode.textContent;
if (newContent !== currentContent) {
currentContent = newContent; // Update tracked content
const newId = `mermaid-${++mermaidIdCounter}`;
mermaid.render(newId, newContent).then(({ svg }) => {
dom.innerHTML = svg;
}).catch((err) => {
dom.innerHTML = `<pre class="mermaid-error">Error rendering diagram: ${err.message}</pre>`;
});
}
return true;
},
};
};
});
// Plugin key for managing comment highlights
const commentHighlightPluginKey = new PluginKey<{
deco: DecorationSet;
comments: CommentHighlight[];
}>('comment-highlight');
// 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 = '';
const positions: number[] = []; // Maps text index to document position
doc.descendants((node, pos) => {
if (node.isText && node.text) {
for (let i = 0; i < node.text.length; i++) {
positions.push(pos + i);
text += node.text[i];
}
} else if (node.type.name === 'hardbreak') {
// Handle hardbreak as a space/newline
positions.push(-1);
text += ' ';
} else if (node.isBlock && text.length > 0 && !text.endsWith('\n')) {
// Add newline between blocks
positions.push(-1); // -1 indicates a synthetic newline
text += '\n';
}
});
return { text, positions };
}
// Normalize text for comparison (collapse whitespace, normalize newlines)
function normalizeForSearch(text: string): string {
return text
.replace(/\r\n/g, '\n')
.replace(/[\t ]+/g, ' ')
.replace(/\n+/g, '\n')
.trim()
.toLowerCase();
}
// Create decorations for comment highlights
function createCommentDecorations(
doc: Parameters<typeof DecorationSet.create>[0],
comments: CommentHighlight[]
): DecorationSet {
const decorations: Decoration[] = [];
if (comments.length === 0) {
return DecorationSet.empty;
}
// Get the full text content with position mapping
const { text: fullText, positions } = getTextWithPositions(doc);
const normalizedFullText = normalizeForSearch(fullText);
comments.forEach((comment) => {
const searchText = normalizeForSearch(comment.selectedText);
if (!searchText || searchText.length < MIN_COMMENT_TEXT_LENGTH) {
return;
}
// Find the text in the normalized full document text
const normalizedIndex = normalizedFullText.indexOf(searchText);
if (normalizedIndex === -1) {
// Try with just the first line as fallback
const firstLine = normalizeForSearch(comment.selectedText.split('\n')[0]);
if (firstLine.length >= MIN_COMMENT_TEXT_LENGTH) {
const fallbackIndex = normalizedFullText.indexOf(firstLine);
if (fallbackIndex !== -1) {
createDecorationForRange(fallbackIndex, firstLine.length, comment, fullText, positions, decorations);
}
}
return;
}
createDecorationForRange(normalizedIndex, searchText.length, comment, fullText, positions, decorations);
});
return DecorationSet.create(doc, decorations);
}
// Helper to create decoration(s) for a text range, handling multi-line selections
function createDecorationForRange(
normalizedIndex: number,
length: number,
comment: CommentHighlight,
fullText: string,
positions: number[],
decorations: Decoration[]
) {
// Map normalized index back to original text index
const normalizedFullText = normalizeForSearch(fullText);
const normalizedChars = normalizedFullText.split('');
const origChars = fullText.split('');
// Map from normalized position to original position
const normToOrig: number[] = [];
let oi = 0;
for (let ni = 0; ni < normalizedChars.length; ni++) {
// Skip whitespace differences in original
while (oi < origChars.length &&
origChars[oi].toLowerCase() !== normalizedChars[ni] &&
/\s/.test(origChars[oi])) {
oi++;
}
normToOrig[ni] = oi;
oi++;
}
const startOrig = normToOrig[normalizedIndex] ?? 0;
const endOrig = normToOrig[normalizedIndex + length - 1] ?? startOrig;
// Now create decorations for each contiguous range (split by newlines/block boundaries)
let rangeStart = -1;
for (let i = startOrig; i <= endOrig && i < positions.length; i++) {
const pos = positions[i];
if (pos === -1) {
// Hit a block boundary - close current range if open
if (rangeStart !== -1) {
const fromPos = positions[rangeStart];
const toPos = positions[i - 1] + 1;
if (fromPos >= 0 && toPos > fromPos) {
decorations.push(Decoration.inline(fromPos, toPos, {
class: 'comment-highlight',
'data-comment-id': comment.id,
title: comment.comment,
}));
}
rangeStart = -1;
}
} else {
// Valid position - start range if not started
if (rangeStart === -1) {
rangeStart = i;
}
}
}
// Close final range
if (rangeStart !== -1 && rangeStart < positions.length) {
const fromPos = positions[rangeStart];
const lastValidIndex = Math.min(endOrig, positions.length - 1);
const toPos = positions[lastValidIndex] + 1;
if (fromPos >= 0 && toPos > fromPos) {
decorations.push(Decoration.inline(fromPos, toPos, {
class: 'comment-highlight',
'data-comment-id': comment.id,
title: comment.comment,
}));
}
}
}
// Create the comment highlight plugin
function createCommentHighlightPlugin(initialComments: CommentHighlight[]) {
return $prose(() => {
return new Plugin({
key: commentHighlightPluginKey,
state: {
init(_, state) {
return {
deco: createCommentDecorations(state.doc, initialComments),
comments: initialComments,
};
},
apply(tr, value, _, newState) {
// Check if comments were updated via meta
const newComments = tr.getMeta(commentHighlightPluginKey);
if (newComments !== undefined) {
return {
deco: createCommentDecorations(newState.doc, newComments),
comments: newComments,
};
}
// If document changed, remap decorations
if (tr.docChanged) {
return {
deco: createCommentDecorations(newState.doc, value.comments),
comments: value.comments,
};
}
return value;
},
},
props: {
decorations(state) {
const pluginState = commentHighlightPluginKey.getState(state);
return pluginState?.deco ?? DecorationSet.empty;
},
},
});
});
}
function MilkdownEditorInner({
value,
onChange,
onSelectionChange,
comments = [],
onCommentClick,
}: MilkdownEditorProps) {
const { resolvedTheme } = useTheme();
const wrapperRef = useRef<HTMLDivElement>(null);
// Capture initial value at mount - editor manages its own state after that
const initialValueRef = useRef(value);
const onChangeRef = useRef(onChange);
const onSelectionChangeRef = useRef(onSelectionChange);
const commentsRef = useRef(comments);
const onCommentClickRef = useRef(onCommentClick);
// Keep onChange ref updated
// Keep refs updated
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const { loading } = useEditor((root) =>
useEffect(() => {
onSelectionChangeRef.current = onSelectionChange;
}, [onSelectionChange]);
useEffect(() => {
commentsRef.current = comments;
}, [comments]);
useEffect(() => {
onCommentClickRef.current = onCommentClick;
}, [onCommentClick]);
// Set up click handler for comment highlights via event delegation
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const commentHighlight = target.closest('.comment-highlight');
if (commentHighlight && onCommentClickRef.current) {
const commentId = commentHighlight.getAttribute('data-comment-id');
if (commentId) {
onCommentClickRef.current(commentId, { x: e.clientX, y: e.clientY });
}
}
};
wrapper.addEventListener('click', handleClick);
return () => wrapper.removeEventListener('click', handleClick);
}, []);
// Handle Cmd+I to trigger selection popover (overrides italic formatting)
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Check for Cmd+I (Mac) or Ctrl+I (Windows/Linux)
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
// Only handle if we have a selection callback
if (!onSelectionChangeRef.current) return;
const selection = window.getSelection();
if (!selection) return;
// Prevent Milkdown's italic formatting immediately
e.preventDefault();
e.stopImmediatePropagation();
// Now check if we have valid selected text
if (selection.isCollapsed || !selection.toString().trim()) {
return;
}
const selectedText = selection.toString().trim();
if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) {
return;
}
// Get position from selection range
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
onSelectionChangeRef.current({
text: selectedText,
position: {
x: rect.left + rect.width / 2,
y: rect.bottom,
},
});
}
};
// Attach to wrapper element in capture phase to intercept before Milkdown
wrapper.addEventListener('keydown', handleKeyDown, true);
return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
}, []);
// Create the highlight plugin once with initial comments
const highlightPlugin = useCallback(
() => createCommentHighlightPlugin(commentsRef.current),
[]
);
const { loading, get } = useEditor((root) =>
Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root);
@@ -38,11 +433,39 @@ function MilkdownEditorInner({ value, onChange }: MilkdownEditorProps) {
.config(nord)
.use(commonmark)
.use(gfm)
.use(mermaidCodeBlockView)
.use(listener)
.use(highlightPlugin())
);
// Update decorations when comments change
useEffect(() => {
const editor = get();
if (!editor || loading) return;
try {
// Get the editor view and dispatch a transaction to update comments
editor.action((ctx) => {
try {
const view = ctx.get(editorViewCtx);
if (view) {
const tr = view.state.tr.setMeta(commentHighlightPluginKey, comments);
view.dispatch(tr);
}
} catch {
// editorViewCtx not available yet
}
});
} catch {
// Editor might not be ready
}
}, [comments, get, loading]);
return (
<div className={`milkdown-wrapper h-full relative ${resolvedTheme === 'dark' ? 'dark' : ''}`}>
<div
ref={wrapperRef}
className={`milkdown-wrapper h-full relative ${resolvedTheme === 'dark' ? 'dark' : ''}`}
>
<Milkdown />
{loading && (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-background/80">
@@ -0,0 +1,5 @@
export type PlanComment = {
id: string;
selectedText: string;
comment: string;
};
@@ -0,0 +1,142 @@
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import { IconPlus } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Textarea } from '@kandev/ui/textarea';
import { cn } from '@/lib/utils';
type SelectionPosition = {
x: number;
y: number;
};
type PlanSelectionPopoverProps = {
selectedText: string;
position: SelectionPosition;
onAdd: (comment: string, selectedText: string) => void;
onClose: () => void;
editingComment?: string; // Pre-fill if editing existing comment
};
export function PlanSelectionPopover({
selectedText,
position,
onAdd,
onClose,
editingComment,
}: PlanSelectionPopoverProps) {
const [comment, setComment] = useState(editingComment || '');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
// Focus textarea on mount
useEffect(() => {
textareaRef.current?.focus();
}, []);
// Close on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
// Delay adding listener to avoid immediate close from selection click
const timer = setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside);
}, 100);
return () => {
clearTimeout(timer);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
// Close on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const handleSubmit = useCallback(() => {
if (!comment.trim()) return;
onAdd(comment.trim(), selectedText);
onClose();
}, [comment, onAdd, selectedText, onClose]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit]
);
const isDisabled = !comment.trim();
// Truncate text for preview
const previewText = selectedText.length > 80
? selectedText.slice(0, 80).trim() + '…'
: selectedText;
// Calculate position - flip above if would overflow bottom
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
return (
<div
ref={popoverRef}
className={cn(
'fixed z-50 w-96 rounded-xl border border-border/50 bg-popover/95 backdrop-blur-sm p-4 shadow-xl',
'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
}}
>
{/* Text preview */}
<p className="mb-3 text-xs text-muted-foreground line-clamp-2 leading-relaxed italic">
&ldquo;{previewText}&rdquo;
</p>
{/* Comment input */}
<Textarea
ref={textareaRef}
value={comment}
onChange={(e) => setComment(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add your comment or instruction..."
className="min-h-[72px] resize-none text-sm border-border/50 focus:border-primary/50"
/>
{/* Actions */}
<div className="mt-3 flex items-center justify-between">
<span className="text-xs text-muted-foreground/70">
⌘+Enter to add
</span>
<Button
size="sm"
onClick={handleSubmit}
disabled={isDisabled}
className="gap-1.5"
>
<IconPlus className="h-3.5 w-3.5" />
{editingComment ? 'Update' : 'Add comment'}
</Button>
</div>
</div>
);
}
@@ -194,6 +194,9 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
const tabs: SessionTab[] = useMemo(() => {
const staticTabs: SessionTab[] = [
{ id: 'notes', label: 'Notes' },
{ id: 'changes', label: 'All changes' },
{ id: 'chat', label: 'Chat' },
{
id: 'plan',
label: 'Plan',
@@ -201,9 +204,6 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
) : undefined,
},
{ id: 'notes', label: 'Notes' },
{ id: 'changes', label: 'All changes' },
{ id: 'chat', label: 'Chat' },
];
const fileTabs: SessionTab[] = openFileTabs.map((tab) => {
+128 -4
View File
@@ -8,6 +8,9 @@ import { useAppStore } from '@/components/state-provider';
import { getWebSocketClient } from '@/lib/ws/connection';
import { TaskPlanToolbar } from './task-plan-toolbar';
import { TaskPlanDialogs } from './task-plan-dialogs';
import { PlanSelectionPopover } from './plan-selection-popover';
import type { PlanComment } from './plan-comments';
import type { TextSelection, CommentHighlight } from './markdown-editor';
// Dynamic import to avoid SSR issues with Milkdown
const MarkdownEditor = dynamic(
@@ -29,14 +32,23 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, showApproveBu
activeSessionId ? state.taskSessions.items[activeSessionId] ?? null : null
);
const isAgentBusy = activeSession?.state === 'STARTING' || activeSession?.state === 'RUNNING';
const [draftContent, setDraftContent] = useState('');
const [draftContent, setDraftContent] = useState(plan?.content ?? '');
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const [showClearDialog, setShowClearDialog] = useState(false);
const [isReanalyzing, setIsReanalyzing] = useState(false);
// Key to force editor remount on external content changes
const [editorKey, setEditorKey] = useState(0);
// Track the last known plan content to detect external updates
const lastPlanContentRef = useRef<string | undefined>(plan?.content);
// Initialize to undefined so the first useEffect run always syncs if plan is already loaded
const lastPlanContentRef = useRef<string | undefined>(undefined);
// Text selection state for the selection popover
const [textSelection, setTextSelection] = useState<TextSelection | null>(null);
// Plan comments state
const [comments, setComments] = useState<PlanComment[]>([]);
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [isSubmittingComments, setIsSubmittingComments] = useState(false);
// Ref to the editor wrapper for positioning gutter markers
const editorWrapperRef = useRef<HTMLDivElement>(null);
// Sync draft with plan content and force editor remount when plan changes externally
useEffect(() => {
@@ -109,6 +121,93 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, showApproveBu
}
}, [deletePlan]);
// Handle adding a comment (from selection popover)
const handleAddComment = useCallback((comment: string, selectedText: string) => {
if (editingCommentId) {
// Update existing comment
setComments(prev => prev.map(c =>
c.id === editingCommentId
? { ...c, comment, selectedText }
: c
));
setEditingCommentId(null);
} else {
// Add new comment
const newComment: PlanComment = {
id: crypto.randomUUID(),
selectedText,
comment,
};
setComments(prev => [...prev, newComment]);
}
}, [editingCommentId]);
const handleSelectionClose = useCallback(() => {
setTextSelection(null);
setEditingCommentId(null);
// Clear the browser selection
window.getSelection()?.removeAllRanges();
}, []);
// Handle clicking on a highlighted comment in the editor (opens edit popover)
const handleCommentHighlightClick = useCallback(
(id: string, position: { x: number; y: number }) => {
const comment = comments.find((c) => c.id === id);
if (comment) {
setEditingCommentId(id);
setTextSelection({
text: comment.selectedText,
position,
});
}
},
[comments]
);
// Handle clearing all comments
const handleClearComments = useCallback(() => {
setComments([]);
}, []);
// Handle submitting all comments to the agent
const handleSubmitComments = useCallback(async () => {
if (!taskId || !activeSessionId || comments.length === 0) return;
const client = getWebSocketClient();
if (!client) return;
setIsSubmittingComments(true);
try {
// Format comments for display (short version)
const commentsList = comments.map((c, i) =>
`${i + 1}. "${c.selectedText.slice(0, 50)}${c.selectedText.length > 50 ? '...' : ''}" → ${c.comment}`
).join('\n');
// Format full details inside system tags (not rendered in UI)
const fullDetails = comments.map((c, i) =>
`Comment ${i + 1}:\n- User comment: ${c.comment}\n- Selected text: "${c.selectedText}"`
).join('\n\n');
const message = `I have feedback on the plan:\n${commentsList}\n\n<kandev-system>\nFull comment details for plan modifications:\n\n${fullDetails}\n\nCurrent plan content:\n${draftContent}\n</kandev-system>`;
await client.request(
'message.add',
{
task_id: taskId,
session_id: activeSessionId,
content: message,
},
10000
);
// Clear comments after successful submission
setComments([]);
} catch (err) {
console.error('Failed to submit comments:', err);
} finally {
setIsSubmittingComments(false);
}
}, [taskId, activeSessionId, comments, draftContent]);
// Ctrl+S to save
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -140,15 +239,25 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, showApproveBu
);
}
// Convert PlanComment to CommentHighlight for the editor
const commentHighlights: CommentHighlight[] = comments.map((c) => ({
id: c.id,
selectedText: c.selectedText,
comment: c.comment,
}));
return (
<div className="flex flex-col h-full">
{/* Content - Markdown Editor */}
<div className="flex-1 min-h-0">
{/* Content - Markdown Editor with inline comment highlights */}
<div className="flex-1 min-h-0 relative" ref={editorWrapperRef}>
<MarkdownEditor
key={`${taskId}-${editorKey}`}
value={draftContent}
onChange={setDraftContent}
placeholder="Start typing your plan..."
onSelectionChange={activeSessionId ? setTextSelection : undefined}
comments={commentHighlights}
onCommentClick={handleCommentHighlightClick}
/>
</div>
@@ -162,6 +271,10 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, showApproveBu
isReanalyzing={isReanalyzing}
hasActiveSession={!!activeSessionId}
showApproveButton={showApproveButton}
commentCount={comments.length}
isSubmittingComments={isSubmittingComments}
onSubmitComments={handleSubmitComments}
onClearComments={handleClearComments}
onSave={handleSave}
onDiscard={() => setShowDiscardDialog(true)}
onCopy={handleCopy}
@@ -179,6 +292,17 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, showApproveBu
onDiscard={handleDiscard}
onClear={handleClear}
/>
{/* Selection popover for adding comments */}
{textSelection && activeSessionId && (
<PlanSelectionPopover
selectedText={textSelection.text}
position={textSelection.position}
onAdd={handleAddComment}
onClose={handleSelectionClose}
editingComment={editingCommentId ? comments.find(c => c.id === editingCommentId)?.comment : undefined}
/>
)}
</div>
);
});
@@ -11,6 +11,7 @@ import {
IconCopy,
IconRefresh,
IconTrash,
IconSend,
} from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
@@ -26,6 +27,12 @@ type TaskPlanToolbarProps = {
isReanalyzing: boolean;
hasActiveSession: boolean;
showApproveButton?: boolean;
// Comments props
commentCount?: number;
isSubmittingComments?: boolean;
onSubmitComments?: () => void;
onClearComments?: () => void;
// Existing handlers
onSave: () => void;
onDiscard: () => void;
onCopy: () => void;
@@ -43,6 +50,10 @@ export const TaskPlanToolbar = memo(function TaskPlanToolbar({
isReanalyzing,
hasActiveSession,
showApproveButton,
commentCount = 0,
isSubmittingComments = false,
onSubmitComments,
onClearComments,
onSave,
onDiscard,
onCopy,
@@ -50,6 +61,7 @@ export const TaskPlanToolbar = memo(function TaskPlanToolbar({
onClear,
onApprove,
}: TaskPlanToolbarProps) {
const hasComments = commentCount > 0;
return (
<div className="mt-2">
<div
@@ -188,6 +200,56 @@ export const TaskPlanToolbar = memo(function TaskPlanToolbar({
<TooltipContent>Clear plan</TooltipContent>
</Tooltip>
{/* Separator before comments */}
{hasComments && <div className="h-4 w-px bg-border mx-1" />}
{/* Comments section */}
{hasComments && (
<div className="flex items-center gap-1">
{/* Clear comments */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer hover:bg-muted/40 text-muted-foreground hover:text-destructive"
onClick={onClearComments}
disabled={isSubmittingComments}
>
<IconX className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Clear all comments</TooltipContent>
</Tooltip>
{/* Submit comments button - round with count inside */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
className="h-7 w-7 rounded-full cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground relative"
onClick={onSubmitComments}
disabled={isSubmittingComments || isAgentBusy}
>
{isSubmittingComments ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<>
<IconSend className="h-3.5 w-3.5" />
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary-foreground text-primary text-[10px] font-semibold">
{commentCount}
</span>
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent>Send {commentCount} comment{commentCount !== 1 ? 's' : ''} to agent</TooltipContent>
</Tooltip>
</div>
)}
{/* Separator before approve button */}
{showApproveButton && onApprove && (
<div className="h-4 w-px bg-border mx-1" />
+1
View File
@@ -60,6 +60,7 @@
"embla-carousel-react": "^8.6.0",
"immer": "^10.1.1",
"input-otp": "^1.4.2",
"mermaid": "^11.12.2",
"next": "16.1.1",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",