feat(comments): add "Add + Run" button to send comments directly to agent (#430)
This commit is contained in:
@@ -17,6 +17,7 @@ type QueueService interface {
|
||||
CancelQueued(ctx context.Context, sessionID string) (*messagequeue.QueuedMessage, error)
|
||||
GetStatus(ctx context.Context, sessionID string) *messagequeue.QueueStatus
|
||||
UpdateMessage(ctx context.Context, sessionID, content string) error
|
||||
AppendContent(ctx context.Context, sessionID, content string) error
|
||||
}
|
||||
|
||||
// QueueHandlers handles message queue operations
|
||||
@@ -41,6 +42,7 @@ func (h *QueueHandlers) RegisterHandlers(d *ws.Dispatcher) {
|
||||
d.RegisterFunc(ws.ActionMessageQueueCancel, h.wsCancelQueue)
|
||||
d.RegisterFunc(ws.ActionMessageQueueGet, h.wsGetQueueStatus)
|
||||
d.RegisterFunc(ws.ActionMessageQueueUpdate, h.wsUpdateMessage)
|
||||
d.RegisterFunc(ws.ActionMessageQueueAppend, h.wsAppendToQueue)
|
||||
}
|
||||
|
||||
// WebSocket Handlers
|
||||
@@ -192,3 +194,58 @@ func (h *QueueHandlers) wsUpdateMessage(ctx context.Context, msg *ws.Message) (*
|
||||
|
||||
return ws.NewResponse(msg.ID, msg.Action, status)
|
||||
}
|
||||
|
||||
type wsAppendToQueueRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
Content string `json:"content"`
|
||||
Model string `json:"model,omitempty"`
|
||||
PlanMode bool `json:"plan_mode,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
func (h *QueueHandlers) wsAppendToQueue(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsAppendToQueueRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
|
||||
if req.SessionID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
|
||||
}
|
||||
if req.TaskID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
|
||||
}
|
||||
if req.Content == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content is required", nil)
|
||||
}
|
||||
|
||||
// Try to append to existing queued message; if none exists, create a new one
|
||||
if err := h.queueService.AppendContent(ctx, req.SessionID, req.Content); err != nil {
|
||||
_, queueErr := h.queueService.QueueMessage(
|
||||
ctx, req.SessionID, req.TaskID, req.Content, req.Model, req.UserID, req.PlanMode, nil,
|
||||
)
|
||||
if queueErr != nil {
|
||||
h.logger.Error("failed to queue message on append fallback", zap.Error(queueErr))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to queue message", nil)
|
||||
}
|
||||
}
|
||||
|
||||
status := h.queueService.GetStatus(ctx, req.SessionID)
|
||||
|
||||
// Publish queue status changed event
|
||||
if h.eventBus != nil {
|
||||
eventData := map[string]interface{}{
|
||||
"session_id": req.SessionID,
|
||||
"is_queued": status.IsQueued,
|
||||
"message": status.Message,
|
||||
}
|
||||
_ = h.eventBus.Publish(ctx, events.MessageQueueStatusChanged, bus.NewEvent(
|
||||
events.MessageQueueStatusChanged,
|
||||
"queue-handlers",
|
||||
eventData,
|
||||
))
|
||||
}
|
||||
|
||||
return ws.NewResponse(msg.ID, msg.Action, status)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type mockQueueService struct {
|
||||
cancelQueuedFunc func(ctx context.Context, sessionID string) (*messagequeue.QueuedMessage, error)
|
||||
getStatusFunc func(ctx context.Context, sessionID string) *messagequeue.QueueStatus
|
||||
updateMessageFunc func(ctx context.Context, sessionID, content string) error
|
||||
appendContentFunc func(ctx context.Context, sessionID, content string) error
|
||||
}
|
||||
|
||||
func (m *mockQueueService) QueueMessage(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []messagequeue.MessageAttachment) (*messagequeue.QueuedMessage, error) {
|
||||
@@ -51,6 +52,13 @@ func (m *mockQueueService) UpdateMessage(ctx context.Context, sessionID, content
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockQueueService) AppendContent(ctx context.Context, sessionID, content string) error {
|
||||
if m.appendContentFunc != nil {
|
||||
return m.appendContentFunc(ctx, sessionID, content)
|
||||
}
|
||||
return errors.New("no queued message")
|
||||
}
|
||||
|
||||
// mockEventBus is a simple mock event bus
|
||||
type mockEventBus struct {
|
||||
publishFunc func(ctx context.Context, subject string, event *bus.Event) error
|
||||
@@ -456,6 +464,120 @@ func TestWsUpdateMessage(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWsAppendToQueue(t *testing.T) {
|
||||
t.Run("appends to existing queued message", func(t *testing.T) {
|
||||
handlers, mockQueue, mockBus := setupQueueHandlers(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mockQueue.appendContentFunc = func(ctx context.Context, sessionID, content string) error {
|
||||
assert.Equal(t, "session-1", sessionID)
|
||||
assert.Equal(t, "new comment", content)
|
||||
return nil
|
||||
}
|
||||
|
||||
mockQueue.getStatusFunc = func(ctx context.Context, sessionID string) *messagequeue.QueueStatus {
|
||||
return &messagequeue.QueueStatus{
|
||||
IsQueued: true,
|
||||
Message: &messagequeue.QueuedMessage{
|
||||
ID: "queue-1",
|
||||
Content: "original\n\n---\n\nnew comment",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
mockBus.publishFunc = func(ctx context.Context, subject string, event *bus.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := createTestMessage(t, ws.ActionMessageQueueAppend, map[string]interface{}{
|
||||
"session_id": "session-1",
|
||||
"task_id": "task-1",
|
||||
"content": "new comment",
|
||||
})
|
||||
|
||||
response, err := handlers.wsAppendToQueue(ctx, msg)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.MessageTypeResponse, response.Type)
|
||||
})
|
||||
|
||||
t.Run("creates new queued message when none exists", func(t *testing.T) {
|
||||
handlers, mockQueue, mockBus := setupQueueHandlers(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// AppendContent returns error (no existing message)
|
||||
mockQueue.appendContentFunc = func(ctx context.Context, sessionID, content string) error {
|
||||
return errors.New("no queued message")
|
||||
}
|
||||
|
||||
// Falls back to QueueMessage
|
||||
mockQueue.queueMessageFunc = func(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []messagequeue.MessageAttachment) (*messagequeue.QueuedMessage, error) {
|
||||
assert.Equal(t, "session-1", sessionID)
|
||||
assert.Equal(t, "task-1", taskID)
|
||||
assert.Equal(t, "new comment", content)
|
||||
return &messagequeue.QueuedMessage{ID: "queue-1", Content: content}, nil
|
||||
}
|
||||
|
||||
mockQueue.getStatusFunc = func(ctx context.Context, sessionID string) *messagequeue.QueueStatus {
|
||||
return &messagequeue.QueueStatus{
|
||||
IsQueued: true,
|
||||
Message: &messagequeue.QueuedMessage{ID: "queue-1", Content: "new comment"},
|
||||
}
|
||||
}
|
||||
|
||||
mockBus.publishFunc = func(ctx context.Context, subject string, event *bus.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := createTestMessage(t, ws.ActionMessageQueueAppend, map[string]interface{}{
|
||||
"session_id": "session-1",
|
||||
"task_id": "task-1",
|
||||
"content": "new comment",
|
||||
})
|
||||
|
||||
response, err := handlers.wsAppendToQueue(ctx, msg)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.MessageTypeResponse, response.Type)
|
||||
})
|
||||
|
||||
t.Run("returns error when content is empty", func(t *testing.T) {
|
||||
handlers, _, _ := setupQueueHandlers(t)
|
||||
ctx := context.Background()
|
||||
|
||||
msg := createTestMessage(t, ws.ActionMessageQueueAppend, map[string]interface{}{
|
||||
"session_id": "session-1",
|
||||
"task_id": "task-1",
|
||||
"content": "",
|
||||
})
|
||||
|
||||
response, err := handlers.wsAppendToQueue(ctx, msg)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.MessageTypeError, response.Type)
|
||||
|
||||
var errorPayload ws.ErrorPayload
|
||||
err = json.Unmarshal(response.Payload, &errorPayload)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, errorPayload.Message, "content is required")
|
||||
})
|
||||
|
||||
t.Run("returns error when session_id missing", func(t *testing.T) {
|
||||
handlers, _, _ := setupQueueHandlers(t)
|
||||
ctx := context.Background()
|
||||
|
||||
msg := createTestMessage(t, ws.ActionMessageQueueAppend, map[string]interface{}{
|
||||
"task_id": "task-1",
|
||||
"content": "test",
|
||||
})
|
||||
|
||||
response, err := handlers.wsAppendToQueue(ctx, msg)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.MessageTypeError, response.Type)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegisterHandlers(t *testing.T) {
|
||||
t.Run("registers all queue handlers", func(t *testing.T) {
|
||||
handlers, _, _ := setupQueueHandlers(t)
|
||||
|
||||
@@ -102,6 +102,26 @@ func (s *Service) GetStatus(ctx context.Context, sessionID string) *QueueStatus
|
||||
}
|
||||
}
|
||||
|
||||
// AppendContent appends content to an existing queued message.
|
||||
// Returns an error if no message is queued (caller should use QueueMessage instead).
|
||||
func (s *Service) AppendContent(ctx context.Context, sessionID, content string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
msg, exists := s.queued[sessionID]
|
||||
if !exists {
|
||||
return fmt.Errorf("no queued message for session %s", sessionID)
|
||||
}
|
||||
|
||||
msg.Content = msg.Content + "\n\n---\n\n" + content
|
||||
s.logger.Info("content appended to queued message",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Int("appended_length", len(content)),
|
||||
zap.Int("total_length", len(msg.Content)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateMessage updates an existing queued message (for arrow up editing)
|
||||
func (s *Service) UpdateMessage(ctx context.Context, sessionID, content string) error {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -192,6 +192,60 @@ func TestGetStatus(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppendContent(t *testing.T) {
|
||||
t.Run("appends content to existing queued message", func(t *testing.T) {
|
||||
svc := setupService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.QueueMessage(ctx, "session-1", "task-1", "original content", "model-1", "user-1", false, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.AppendContent(ctx, "session-1", "appended content")
|
||||
require.NoError(t, err)
|
||||
|
||||
status := svc.GetStatus(ctx, "session-1")
|
||||
assert.True(t, status.IsQueued)
|
||||
assert.Equal(t, "original content\n\n---\n\nappended content", status.Message.Content)
|
||||
})
|
||||
|
||||
t.Run("appends multiple times", func(t *testing.T) {
|
||||
svc := setupService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.QueueMessage(ctx, "session-1", "task-1", "first", "model-1", "user-1", false, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, svc.AppendContent(ctx, "session-1", "second"))
|
||||
require.NoError(t, svc.AppendContent(ctx, "session-1", "third"))
|
||||
|
||||
status := svc.GetStatus(ctx, "session-1")
|
||||
assert.Equal(t, "first\n\n---\n\nsecond\n\n---\n\nthird", status.Message.Content)
|
||||
})
|
||||
|
||||
t.Run("returns error when no message queued", func(t *testing.T) {
|
||||
svc := setupService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
err := svc.AppendContent(ctx, "session-1", "content")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no queued message")
|
||||
})
|
||||
|
||||
t.Run("preserves message ID after append", func(t *testing.T) {
|
||||
svc := setupService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
msg, err := svc.QueueMessage(ctx, "session-1", "task-1", "original", "model-1", "user-1", false, nil)
|
||||
require.NoError(t, err)
|
||||
originalID := msg.ID
|
||||
|
||||
require.NoError(t, svc.AppendContent(ctx, "session-1", "extra"))
|
||||
|
||||
status := svc.GetStatus(ctx, "session-1")
|
||||
assert.Equal(t, originalID, status.Message.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateMessage(t *testing.T) {
|
||||
t.Run("updates existing queued message content", func(t *testing.T) {
|
||||
svc := setupService(t)
|
||||
|
||||
@@ -111,6 +111,7 @@ const (
|
||||
ActionMessageQueueCancel = "message.queue.cancel"
|
||||
ActionMessageQueueGet = "message.queue.get"
|
||||
ActionMessageQueueUpdate = "message.queue.update"
|
||||
ActionMessageQueueAppend = "message.queue.append"
|
||||
ActionMessageQueueStatusChanged = "message.queue.status_changed" // Notification: queue status changed
|
||||
|
||||
// Workflow template/step actions
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { IconTrash, IconEdit, IconMessage } from "@tabler/icons-react";
|
||||
import { IconTrash, IconEdit, IconMessage, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import type { DiffComment } from "@/lib/diff/types";
|
||||
import { formatLineRange } from "@/lib/diff";
|
||||
|
||||
@@ -12,16 +12,85 @@ interface CommentDisplayProps {
|
||||
onDelete?: () => void;
|
||||
/** Callback to edit the comment */
|
||||
onEdit?: () => void;
|
||||
/** Callback to run/send the comment to the agent immediately */
|
||||
onRun?: () => void;
|
||||
/** Whether to show the code content */
|
||||
showCode?: boolean;
|
||||
/** Compact mode for inline display */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
type ActionButtonsProps = {
|
||||
onRun?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
size: "compact" | "normal";
|
||||
stopPropagation?: boolean;
|
||||
};
|
||||
|
||||
function CommentActionButtons({
|
||||
onRun,
|
||||
onEdit,
|
||||
onDelete,
|
||||
size,
|
||||
stopPropagation,
|
||||
}: ActionButtonsProps) {
|
||||
const btnClass = size === "compact" ? "h-4 w-4 p-0" : "h-5 w-5 p-0";
|
||||
const wrap = (fn?: () => void) =>
|
||||
stopPropagation
|
||||
? (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
fn?.();
|
||||
}
|
||||
: fn;
|
||||
|
||||
return (
|
||||
<>
|
||||
{onRun && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={wrap(onRun)}
|
||||
aria-label="Run comment"
|
||||
title="Run comment"
|
||||
className={`cursor-pointer ${btnClass} text-emerald-600 hover:text-emerald-500 dark:text-emerald-400`}
|
||||
>
|
||||
<IconPlayerPlay className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={wrap(onEdit)}
|
||||
aria-label="Edit comment"
|
||||
title="Edit comment"
|
||||
className={`cursor-pointer ${btnClass}`}
|
||||
>
|
||||
<IconEdit className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={wrap(onDelete)}
|
||||
aria-label="Delete comment"
|
||||
title="Delete comment"
|
||||
className={`cursor-pointer ${btnClass} hover:text-destructive`}
|
||||
>
|
||||
<IconTrash className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommentDisplay({
|
||||
comment,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onRun,
|
||||
showCode = false,
|
||||
compact = false,
|
||||
}: CommentDisplayProps) {
|
||||
@@ -40,32 +109,13 @@ export function CommentDisplay({
|
||||
<span className="break-words">{comment.text}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-0.5 opacity-0 group-hover:opacity-100">
|
||||
{onEdit && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className="h-4 w-4 cursor-pointer p-0"
|
||||
>
|
||||
<IconEdit className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="h-4 w-4 cursor-pointer p-0 hover:text-destructive"
|
||||
>
|
||||
<IconTrash className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<CommentActionButtons
|
||||
onRun={onRun}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
size="compact"
|
||||
stopPropagation
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -73,7 +123,6 @@ export function CommentDisplay({
|
||||
|
||||
return (
|
||||
<div className="group rounded-md border border-border bg-card p-2 shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<IconMessage className="h-3.5 w-3.5 text-blue-500" />
|
||||
@@ -83,37 +132,14 @@ export function CommentDisplay({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{onEdit && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onEdit}
|
||||
className="h-5 w-5 cursor-pointer p-0"
|
||||
>
|
||||
<IconEdit className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDelete}
|
||||
className="h-5 w-5 cursor-pointer p-0 hover:text-destructive"
|
||||
>
|
||||
<IconTrash className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<CommentActionButtons onRun={onRun} onEdit={onEdit} onDelete={onDelete} size="normal" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code preview */}
|
||||
{showCode && comment.codeContent && (
|
||||
<pre className="mb-2 overflow-x-auto rounded bg-muted p-1.5 text-[10px] leading-tight">
|
||||
<code>{comment.codeContent}</code>
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Comment text */}
|
||||
<p className="whitespace-pre-wrap text-xs leading-relaxed text-foreground">{comment.text}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,25 +3,94 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import { IconSend, IconX } from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { IconPlayerPlay, IconSend, IconX } from "@tabler/icons-react";
|
||||
|
||||
interface CommentFormProps {
|
||||
/** Initial content for editing */
|
||||
initialContent?: string;
|
||||
/** Callback when comment is submitted */
|
||||
onSubmit: (content: string) => void;
|
||||
/** Callback when form is cancelled */
|
||||
onCancel: () => void;
|
||||
/** Whether the form is in editing mode */
|
||||
onSubmitAndRun?: (content: string) => void;
|
||||
isEditing?: boolean;
|
||||
/** Auto focus the textarea */
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
function ActionButtons({
|
||||
disabled,
|
||||
modKey,
|
||||
isEditing,
|
||||
showRunButton,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
onSubmitAndRun,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
modKey: string;
|
||||
isEditing: boolean;
|
||||
showRunButton: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
onSubmitAndRun?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="h-6 cursor-pointer px-2 text-xs"
|
||||
>
|
||||
<IconX className="mr-1 h-3 w-3" />
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="inline-flex">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showRunButton ? "outline" : "default"}
|
||||
onClick={onSubmit}
|
||||
disabled={disabled}
|
||||
className={`h-6 cursor-pointer px-2 text-xs ${showRunButton ? "rounded-r-none border-r-0" : ""}`}
|
||||
>
|
||||
<IconSend className="mr-1 h-3 w-3" />
|
||||
{isEditing ? "Update" : "Add"}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Save comment for review ({modKey}+Enter)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{showRunButton && onSubmitAndRun && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSubmitAndRun}
|
||||
disabled={disabled}
|
||||
className="h-6 cursor-pointer gap-1 rounded-l-none px-2 text-xs"
|
||||
>
|
||||
<IconPlayerPlay className="h-3 w-3" />
|
||||
Run
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Save and send to agent ({modKey}+Shift+Enter)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommentForm({
|
||||
initialContent = "",
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onSubmitAndRun,
|
||||
isEditing = false,
|
||||
autoFocus = true,
|
||||
}: CommentFormProps) {
|
||||
@@ -29,9 +98,7 @@ export function CommentForm({
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
if (autoFocus && textareaRef.current) textareaRef.current.focus();
|
||||
}, [autoFocus]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
@@ -42,16 +109,31 @@ export function CommentForm({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitAndRun = () => {
|
||||
const trimmed = content.trim();
|
||||
if (trimmed && onSubmitAndRun) {
|
||||
onSubmitAndRun(trimmed);
|
||||
setContent("");
|
||||
}
|
||||
};
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && navigator.platform?.includes("Mac");
|
||||
const modKey = isMac ? "⌘" : "Ctrl";
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
if (e.shiftKey && onSubmitAndRun) handleSubmitAndRun();
|
||||
else handleSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const disabled = !content.trim();
|
||||
const showRunButton = !!onSubmitAndRun && !isEditing;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-border bg-card p-2 shadow-md">
|
||||
<Textarea
|
||||
@@ -65,29 +147,17 @@ export function CommentForm({
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{typeof navigator !== "undefined" && navigator.platform?.includes("Mac") ? "⌘" : "Ctrl"}
|
||||
+Enter to submit
|
||||
{modKey}+Enter to add{onSubmitAndRun ? `, ${modKey}+Shift+Enter to run` : ""}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="h-6 cursor-pointer px-2 text-xs"
|
||||
>
|
||||
<IconX className="mr-1 h-3 w-3" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!content.trim()}
|
||||
className="h-6 cursor-pointer px-2 text-xs"
|
||||
>
|
||||
<IconSend className="mr-1 h-3 w-3" />
|
||||
{isEditing ? "Update" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
<ActionButtons
|
||||
disabled={disabled}
|
||||
modKey={modKey}
|
||||
isEditing={isEditing}
|
||||
showRunButton={showRunButton}
|
||||
onCancel={onCancel}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmitAndRun={showRunButton ? handleSubmitAndRun : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ interface DiffViewerResolverProps {
|
||||
sessionId?: string;
|
||||
onCommentAdd?: (comment: DiffComment) => void;
|
||||
onCommentDelete?: (commentId: string) => void;
|
||||
onCommentRun?: (comment: DiffComment) => void;
|
||||
comments?: DiffComment[];
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
|
||||
@@ -24,6 +24,7 @@ interface DiffViewerProps {
|
||||
sessionId?: string;
|
||||
onCommentAdd?: (comment: DiffComment) => void;
|
||||
onCommentDelete?: (commentId: string) => void;
|
||||
onCommentRun?: (comment: DiffComment) => void;
|
||||
comments?: DiffComment[];
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
@@ -105,6 +106,7 @@ export const DiffViewer = memo(function DiffViewer({
|
||||
sessionId,
|
||||
onCommentAdd,
|
||||
onCommentDelete,
|
||||
onCommentRun,
|
||||
comments: externalComments,
|
||||
className,
|
||||
compact = false,
|
||||
@@ -130,6 +132,7 @@ export const DiffViewer = memo(function DiffViewer({
|
||||
sessionId,
|
||||
onCommentAdd,
|
||||
onCommentDelete,
|
||||
onCommentRun,
|
||||
externalComments,
|
||||
onRevertBlock,
|
||||
enableExpansion,
|
||||
@@ -147,8 +150,10 @@ export const DiffViewer = memo(function DiffViewer({
|
||||
onButtonEnter,
|
||||
onButtonLeave,
|
||||
handleCommentSubmit: state.handleCommentSubmit,
|
||||
handleCommentSubmitAndRun: state.handleCommentSubmitAndRun,
|
||||
handleCommentUpdate: state.handleCommentUpdate,
|
||||
handleCommentDelete: state.handleCommentDelete,
|
||||
handleCommentRun: onCommentRun,
|
||||
setShowCommentForm: state.setShowCommentForm,
|
||||
setSelectedLines: state.setSelectedLines,
|
||||
setEditingComment: state.setEditingComment,
|
||||
|
||||
@@ -14,6 +14,7 @@ interface FileDiffViewerProps {
|
||||
sessionId?: string;
|
||||
onCommentAdd?: (comment: DiffComment) => void;
|
||||
onCommentDelete?: (commentId: string) => void;
|
||||
onCommentRun?: (comment: DiffComment) => void;
|
||||
comments?: DiffComment[];
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
@@ -46,6 +47,7 @@ export const FileDiffViewer = memo(function FileDiffViewer({
|
||||
sessionId,
|
||||
onCommentAdd,
|
||||
onCommentDelete,
|
||||
onCommentRun,
|
||||
comments,
|
||||
className,
|
||||
compact,
|
||||
@@ -67,6 +69,7 @@ export const FileDiffViewer = memo(function FileDiffViewer({
|
||||
sessionId={sessionId}
|
||||
onCommentAdd={onCommentAdd}
|
||||
onCommentDelete={onCommentDelete}
|
||||
onCommentRun={onCommentRun}
|
||||
comments={comments}
|
||||
className={className}
|
||||
compact={compact}
|
||||
|
||||
@@ -17,8 +17,10 @@ type UseAnnotationRendererOpts = {
|
||||
onButtonEnter: () => void;
|
||||
onButtonLeave: () => void;
|
||||
handleCommentSubmit: (content: string) => void;
|
||||
handleCommentSubmitAndRun?: (content: string) => void;
|
||||
handleCommentUpdate: (commentId: string, content: string) => void;
|
||||
handleCommentDelete: (commentId: string) => void;
|
||||
handleCommentRun?: (comment: DiffComment) => void;
|
||||
setShowCommentForm: (show: boolean) => void;
|
||||
setSelectedLines: (lines: null) => void;
|
||||
setEditingComment: (id: string | null) => void;
|
||||
@@ -32,8 +34,10 @@ export function useAnnotationRenderer(opts: UseAnnotationRendererOpts) {
|
||||
onButtonEnter,
|
||||
onButtonLeave,
|
||||
handleCommentSubmit,
|
||||
handleCommentSubmitAndRun,
|
||||
handleCommentUpdate,
|
||||
handleCommentDelete,
|
||||
handleCommentRun,
|
||||
setShowCommentForm,
|
||||
setSelectedLines,
|
||||
setEditingComment,
|
||||
@@ -60,6 +64,7 @@ export function useAnnotationRenderer(opts: UseAnnotationRendererOpts) {
|
||||
<div className="my-1 px-2">
|
||||
<CommentForm
|
||||
onSubmit={handleCommentSubmit}
|
||||
onSubmitAndRun={handleCommentSubmitAndRun}
|
||||
onCancel={() => {
|
||||
setShowCommentForm(false);
|
||||
setSelectedLines(null);
|
||||
@@ -89,6 +94,7 @@ export function useAnnotationRenderer(opts: UseAnnotationRendererOpts) {
|
||||
comment={comment}
|
||||
onDelete={() => handleCommentDelete(comment.id)}
|
||||
onEdit={() => setEditingComment(comment.id)}
|
||||
onRun={handleCommentRun ? () => handleCommentRun(comment) : undefined}
|
||||
showCode={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -100,8 +106,10 @@ export function useAnnotationRenderer(opts: UseAnnotationRendererOpts) {
|
||||
[
|
||||
setEditingComment,
|
||||
handleCommentDelete,
|
||||
handleCommentRun,
|
||||
handleCommentUpdate,
|
||||
handleCommentSubmit,
|
||||
handleCommentSubmitAndRun,
|
||||
handleRevertBlock,
|
||||
onButtonEnter,
|
||||
onButtonLeave,
|
||||
|
||||
@@ -129,6 +129,7 @@ type UseDiffViewerStateOpts = {
|
||||
sessionId?: string;
|
||||
onCommentAdd?: (comment: DiffComment) => void;
|
||||
onCommentDelete?: (commentId: string) => void;
|
||||
onCommentRun?: (comment: DiffComment) => void;
|
||||
externalComments?: DiffComment[];
|
||||
onRevertBlock?: (filePath: string, info: RevertBlockInfo) => Promise<void> | void;
|
||||
/** Enable diff expansion (requires fetching full file content) */
|
||||
@@ -178,26 +179,13 @@ function useDiffViewerAnnotations({
|
||||
return annotations;
|
||||
}
|
||||
|
||||
function useDiffViewerCommentHandlers({
|
||||
selectedLines,
|
||||
setSelectedLines,
|
||||
setShowCommentForm,
|
||||
enableComments,
|
||||
onCommentAdd,
|
||||
externalComments,
|
||||
data,
|
||||
sessionId,
|
||||
addComment,
|
||||
removeComment,
|
||||
updateComment,
|
||||
setEditingComment,
|
||||
onCommentDelete,
|
||||
}: {
|
||||
type CommentHandlerOpts = {
|
||||
selectedLines: SelectedLineRange | null;
|
||||
setSelectedLines: React.Dispatch<React.SetStateAction<SelectedLineRange | null>>;
|
||||
setShowCommentForm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
enableComments: boolean;
|
||||
onCommentAdd?: (comment: DiffComment) => void;
|
||||
onCommentRun?: (comment: DiffComment) => void;
|
||||
externalComments?: DiffComment[];
|
||||
data: FileDiffData;
|
||||
sessionId?: string;
|
||||
@@ -206,7 +194,25 @@ function useDiffViewerCommentHandlers({
|
||||
updateComment: (commentId: string, updates: Partial<DiffComment>) => void;
|
||||
setEditingComment: (commentId: string | null) => void;
|
||||
onCommentDelete?: (commentId: string) => void;
|
||||
}) {
|
||||
};
|
||||
|
||||
function useDiffViewerCommentHandlers(opts: CommentHandlerOpts) {
|
||||
const {
|
||||
selectedLines,
|
||||
setSelectedLines,
|
||||
setShowCommentForm,
|
||||
enableComments,
|
||||
onCommentAdd,
|
||||
onCommentRun,
|
||||
externalComments,
|
||||
data,
|
||||
sessionId,
|
||||
addComment,
|
||||
removeComment,
|
||||
updateComment,
|
||||
setEditingComment,
|
||||
onCommentDelete,
|
||||
} = opts;
|
||||
const handleLineSelectionEnd = useCallback(
|
||||
(range: SelectedLineRange | null) => {
|
||||
setSelectedLines(range);
|
||||
@@ -215,22 +221,36 @@ function useDiffViewerCommentHandlers({
|
||||
[enableComments, setSelectedLines, setShowCommentForm],
|
||||
);
|
||||
|
||||
const handleCommentSubmit = useCallback(
|
||||
(content: string) => {
|
||||
const createCommentFromSelection = useCallback(
|
||||
(content: string): DiffComment | null => {
|
||||
if (!selectedLines) return null;
|
||||
return buildDiffComment({
|
||||
filePath: data.filePath,
|
||||
sessionId: sessionId || "",
|
||||
startLine: selectedLines.start,
|
||||
endLine: selectedLines.end,
|
||||
side: (selectedLines.side || "additions") as DiffComment["side"],
|
||||
text: content,
|
||||
});
|
||||
},
|
||||
[selectedLines, data.filePath, sessionId],
|
||||
);
|
||||
|
||||
const submitComment = useCallback(
|
||||
(content: string, runAfter?: (c: DiffComment) => void) => {
|
||||
if (!selectedLines) return;
|
||||
if (onCommentAdd && externalComments !== undefined) {
|
||||
onCommentAdd(
|
||||
buildDiffComment({
|
||||
filePath: data.filePath,
|
||||
sessionId: sessionId || "",
|
||||
startLine: selectedLines.start,
|
||||
endLine: selectedLines.end,
|
||||
side: (selectedLines.side || "additions") as DiffComment["side"],
|
||||
text: content,
|
||||
}),
|
||||
);
|
||||
const comment = createCommentFromSelection(content);
|
||||
if (comment) {
|
||||
onCommentAdd(comment);
|
||||
runAfter?.(comment);
|
||||
}
|
||||
} else if (sessionId) {
|
||||
addComment(selectedLines, content);
|
||||
if (runAfter) {
|
||||
const comment = createCommentFromSelection(content);
|
||||
if (comment) runAfter(comment);
|
||||
}
|
||||
}
|
||||
setShowCommentForm(false);
|
||||
setSelectedLines(null);
|
||||
@@ -239,14 +259,24 @@ function useDiffViewerCommentHandlers({
|
||||
selectedLines,
|
||||
onCommentAdd,
|
||||
externalComments,
|
||||
data.filePath,
|
||||
sessionId,
|
||||
addComment,
|
||||
createCommentFromSelection,
|
||||
setShowCommentForm,
|
||||
setSelectedLines,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCommentSubmit = useCallback(
|
||||
(content: string) => submitComment(content),
|
||||
[submitComment],
|
||||
);
|
||||
|
||||
const handleCommentSubmitAndRun = useCallback(
|
||||
(content: string) => submitComment(content, onCommentRun),
|
||||
[submitComment, onCommentRun],
|
||||
);
|
||||
|
||||
const { handleCommentDelete, handleCommentUpdate } = useCommentActions({
|
||||
removeComment,
|
||||
updateComment,
|
||||
@@ -255,7 +285,13 @@ function useDiffViewerCommentHandlers({
|
||||
externalComments,
|
||||
});
|
||||
|
||||
return { handleLineSelectionEnd, handleCommentSubmit, handleCommentDelete, handleCommentUpdate };
|
||||
return {
|
||||
handleLineSelectionEnd,
|
||||
handleCommentSubmit,
|
||||
handleCommentSubmitAndRun: onCommentRun ? handleCommentSubmitAndRun : undefined,
|
||||
handleCommentDelete,
|
||||
handleCommentUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
function useRevertBlock(
|
||||
@@ -282,6 +318,7 @@ export function useDiffViewerState(opts: UseDiffViewerStateOpts) {
|
||||
sessionId,
|
||||
onCommentAdd,
|
||||
onCommentDelete,
|
||||
onCommentRun,
|
||||
externalComments,
|
||||
onRevertBlock,
|
||||
enableExpansion = false,
|
||||
@@ -308,15 +345,7 @@ export function useDiffViewerState(opts: UseDiffViewerStateOpts) {
|
||||
|
||||
const comments = externalComments || internalComments;
|
||||
const baseDiffMetadata = useDiffMetadata(data);
|
||||
|
||||
const {
|
||||
metadata: fileDiffMetadata,
|
||||
isContentLoaded: isExpansionContentLoaded,
|
||||
isLoading: isExpansionLoading,
|
||||
error: expansionError,
|
||||
loadContent: loadExpansionContent,
|
||||
canExpand,
|
||||
} = useExpandableDiff({
|
||||
const expansion = useExpandableDiff({
|
||||
sessionId,
|
||||
filePath: data.filePath,
|
||||
baseRef,
|
||||
@@ -334,31 +363,31 @@ export function useDiffViewerState(opts: UseDiffViewerStateOpts) {
|
||||
showCommentForm,
|
||||
selectedLines,
|
||||
enableAcceptReject,
|
||||
fileDiffMetadata,
|
||||
fileDiffMetadata: expansion.metadata,
|
||||
changeLineMapRef,
|
||||
revertInfoRef,
|
||||
});
|
||||
|
||||
const { handleLineSelectionEnd, handleCommentSubmit, handleCommentDelete, handleCommentUpdate } =
|
||||
useDiffViewerCommentHandlers({
|
||||
selectedLines,
|
||||
setSelectedLines,
|
||||
setShowCommentForm,
|
||||
enableComments,
|
||||
onCommentAdd,
|
||||
externalComments,
|
||||
data,
|
||||
sessionId,
|
||||
addComment,
|
||||
removeComment,
|
||||
updateComment,
|
||||
setEditingComment,
|
||||
onCommentDelete,
|
||||
});
|
||||
const commentHandlers = useDiffViewerCommentHandlers({
|
||||
selectedLines,
|
||||
setSelectedLines,
|
||||
setShowCommentForm,
|
||||
enableComments,
|
||||
onCommentAdd,
|
||||
onCommentRun,
|
||||
externalComments,
|
||||
data,
|
||||
sessionId,
|
||||
addComment,
|
||||
removeComment,
|
||||
updateComment,
|
||||
setEditingComment,
|
||||
onCommentDelete,
|
||||
});
|
||||
|
||||
return {
|
||||
comments,
|
||||
fileDiffMetadata,
|
||||
fileDiffMetadata: expansion.metadata,
|
||||
annotations,
|
||||
selectedLines,
|
||||
showCommentForm,
|
||||
@@ -367,17 +396,13 @@ export function useDiffViewerState(opts: UseDiffViewerStateOpts) {
|
||||
editingCommentId,
|
||||
setEditingComment,
|
||||
handleRevertBlock,
|
||||
handleLineSelectionEnd,
|
||||
handleCommentSubmit,
|
||||
handleCommentDelete,
|
||||
handleCommentUpdate,
|
||||
...commentHandlers,
|
||||
changeLineMapRef,
|
||||
hideTimeoutRef,
|
||||
// Expansion state
|
||||
isExpansionContentLoaded,
|
||||
isExpansionLoading,
|
||||
expansionError,
|
||||
loadExpansionContent,
|
||||
canExpand,
|
||||
isExpansionContentLoaded: expansion.isContentLoaded,
|
||||
isExpansionLoading: expansion.isLoading,
|
||||
expansionError: expansion.error,
|
||||
loadExpansionContent: expansion.loadContent,
|
||||
canExpand: expansion.canExpand,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -323,6 +323,7 @@ export function CodeMirrorCodeEditor({
|
||||
lineRange={{ start: state.textSelection.startLine, end: state.textSelection.endLine }}
|
||||
position={state.textSelection.position}
|
||||
onSubmit={state.handleCommentSubmit}
|
||||
onSubmitAndRun={state.handleCommentSubmitAndRun}
|
||||
onClose={state.handlePopoverClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Decoration, type DecorationSet } from "@codemirror/view";
|
||||
import { getCodeMirrorExtensionFromPath } from "@/lib/languages";
|
||||
import { useCommentsStore } from "@/lib/state/slices/comments";
|
||||
import { useDiffFileComments } from "@/hooks/domains/comments/use-diff-comments";
|
||||
import { useRunComment } from "@/hooks/domains/comments/use-run-comment";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { DiffComment } from "@/lib/diff/types";
|
||||
import { computeLineDiffStats } from "@/lib/diff";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
@@ -426,9 +428,21 @@ export function useCodeMirrorEditorState(opts: UseCodeMirrorEditorStateOpts) {
|
||||
[currentSelection, floatingButtonPos],
|
||||
);
|
||||
|
||||
const handleCommentSubmit = useCallback(
|
||||
(annotation: string) => {
|
||||
if (!textSelection || !sessionId) return;
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const activeSession = useAppStore((state) => {
|
||||
const sid = state.tasks.activeSessionId;
|
||||
return sid ? (state.taskSessions.items[sid] ?? null) : null;
|
||||
});
|
||||
const isAgentBusy = activeSession?.state === "STARTING" || activeSession?.state === "RUNNING";
|
||||
const { runComment } = useRunComment({
|
||||
sessionId: sessionId ?? null,
|
||||
taskId: activeTaskId ?? null,
|
||||
isAgentBusy,
|
||||
});
|
||||
|
||||
const createCommentFromSelection = useCallback(
|
||||
(annotation: string): DiffComment | null => {
|
||||
if (!textSelection || !sessionId) return null;
|
||||
const comment: DiffComment = {
|
||||
id: `${path}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
source: "diff",
|
||||
@@ -448,12 +462,36 @@ export function useCodeMirrorEditorState(opts: UseCodeMirrorEditorStateOpts) {
|
||||
if (view) {
|
||||
view.dispatch({ selection: { anchor: view.state.selection.main.head } });
|
||||
}
|
||||
toast({
|
||||
title: "Comment added",
|
||||
description: "Your comment will be sent with your next message.",
|
||||
});
|
||||
return comment;
|
||||
},
|
||||
[textSelection, sessionId, path, addComment, toast, editorRef],
|
||||
[textSelection, sessionId, path, addComment, editorRef],
|
||||
);
|
||||
|
||||
const handleCommentSubmit = useCallback(
|
||||
(annotation: string) => {
|
||||
const comment = createCommentFromSelection(annotation);
|
||||
if (comment) {
|
||||
toast({
|
||||
title: "Comment added",
|
||||
description: "Your comment will be sent with your next message.",
|
||||
});
|
||||
}
|
||||
},
|
||||
[createCommentFromSelection, toast],
|
||||
);
|
||||
|
||||
const handleCommentSubmitAndRun = useCallback(
|
||||
(annotation: string) => {
|
||||
const comment = createCommentFromSelection(annotation);
|
||||
if (comment) {
|
||||
runComment(comment);
|
||||
toast({
|
||||
title: "Comment sent",
|
||||
description: isAgentBusy ? "Queued for the agent." : "Sent to the agent.",
|
||||
});
|
||||
}
|
||||
},
|
||||
[createCommentFromSelection, runComment, isAgentBusy, toast],
|
||||
);
|
||||
|
||||
const handlePopoverClose = useCallback(() => {
|
||||
@@ -493,6 +531,7 @@ export function useCodeMirrorEditorState(opts: UseCodeMirrorEditorStateOpts) {
|
||||
handleChange,
|
||||
handleFloatingButtonClick,
|
||||
handleCommentSubmit,
|
||||
handleCommentSubmitAndRun,
|
||||
handlePopoverClose,
|
||||
handleDeleteComment,
|
||||
handleCommentViewClose,
|
||||
|
||||
@@ -80,6 +80,7 @@ function buildCommentZones(
|
||||
comment={comment}
|
||||
onDelete={() => state.handleCommentDeleteRef.current(comment.id)}
|
||||
onEdit={() => state.setEditingComment(comment.id)}
|
||||
onRun={() => state.handleCommentRunRef.current(comment)}
|
||||
showCode={false}
|
||||
compact
|
||||
/>
|
||||
@@ -94,6 +95,7 @@ function buildCommentZones(
|
||||
<div className="px-2 py-1" data-comment-zone>
|
||||
<CommentForm
|
||||
onSubmit={(c) => state.handleCommentSubmitRef.current(c)}
|
||||
onSubmitAndRun={(c) => state.handleCommentSubmitAndRunRef.current(c)}
|
||||
onCancel={() => {
|
||||
state.setFormZoneRange(null);
|
||||
state.clearGutterSelection();
|
||||
|
||||
@@ -28,6 +28,7 @@ interface MonacoDiffViewerProps {
|
||||
sessionId?: string;
|
||||
onCommentAdd?: (comment: DiffComment) => void;
|
||||
onCommentDelete?: (commentId: string) => void;
|
||||
onCommentRun?: (comment: DiffComment) => void;
|
||||
comments?: DiffComment[];
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
@@ -46,6 +47,7 @@ function useMonacoDiffViewerState(props: MonacoDiffViewerProps) {
|
||||
compact = false,
|
||||
onCommentAdd,
|
||||
onCommentDelete,
|
||||
onCommentRun,
|
||||
comments: externalComments,
|
||||
onModifiedContentChange,
|
||||
wordWrap: wordWrapProp,
|
||||
@@ -60,20 +62,13 @@ function useMonacoDiffViewerState(props: MonacoDiffViewerProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const { setOpen: setCommandPanelOpen } = useCommandPanelOpen();
|
||||
|
||||
const {
|
||||
diffEditorRef,
|
||||
modifiedEditor,
|
||||
originalEditor,
|
||||
contextMenu,
|
||||
setContextMenu,
|
||||
copyAllChangedLines,
|
||||
handleDiffEditorMount,
|
||||
} = useDiffViewerComments({
|
||||
const commentState = useDiffViewerComments({
|
||||
data,
|
||||
sessionId,
|
||||
compact,
|
||||
onCommentAdd,
|
||||
onCommentDelete,
|
||||
onCommentRun,
|
||||
externalComments,
|
||||
onModifiedContentChange,
|
||||
});
|
||||
@@ -81,7 +76,7 @@ function useMonacoDiffViewerState(props: MonacoDiffViewerProps) {
|
||||
useCaptureKeydown(wrapperRef, { metaOrCtrl: true, key: "k" }, () => setCommandPanelOpen(true));
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const ref = diffEditorRef;
|
||||
const ref = commentState.diffEditorRef;
|
||||
return () => {
|
||||
try {
|
||||
ref.current?.setModel(null);
|
||||
@@ -89,7 +84,7 @@ function useMonacoDiffViewerState(props: MonacoDiffViewerProps) {
|
||||
/* already disposed */
|
||||
}
|
||||
};
|
||||
}, [diffEditorRef]);
|
||||
}, [commentState.diffEditorRef]);
|
||||
|
||||
const { oldContent, newContent, diff, filePath } = data;
|
||||
const language = getMonacoLanguage(filePath);
|
||||
@@ -99,26 +94,13 @@ function useMonacoDiffViewerState(props: MonacoDiffViewerProps) {
|
||||
);
|
||||
const lineHeight = compact ? 16 : 18;
|
||||
const editorHeight = useDiffEditorHeight({
|
||||
modifiedEditor,
|
||||
originalEditor,
|
||||
modifiedEditor: commentState.modifiedEditor,
|
||||
originalEditor: commentState.originalEditor,
|
||||
compact,
|
||||
lineHeight,
|
||||
originalContent: original,
|
||||
modifiedContent: modified,
|
||||
});
|
||||
const modifiedReadOnly = compact || (!editable && !onRevert);
|
||||
const monacoTheme = getMonacoTheme(resolvedTheme);
|
||||
const hasDiff = !!(oldContent || newContent || diff);
|
||||
|
||||
const options = buildDiffEditorOptions({
|
||||
compact,
|
||||
wordWrap,
|
||||
modifiedReadOnly,
|
||||
onRevert,
|
||||
globalViewMode,
|
||||
foldUnchanged,
|
||||
lineHeight,
|
||||
});
|
||||
|
||||
return {
|
||||
resolvedTheme,
|
||||
@@ -129,10 +111,7 @@ function useMonacoDiffViewerState(props: MonacoDiffViewerProps) {
|
||||
wordWrap,
|
||||
setWordWrap,
|
||||
wrapperRef,
|
||||
contextMenu,
|
||||
setContextMenu,
|
||||
copyAllChangedLines,
|
||||
handleDiffEditorMount,
|
||||
...commentState,
|
||||
diff,
|
||||
filePath,
|
||||
language,
|
||||
@@ -140,9 +119,17 @@ function useMonacoDiffViewerState(props: MonacoDiffViewerProps) {
|
||||
modified,
|
||||
lineHeight,
|
||||
editorHeight,
|
||||
hasDiff,
|
||||
monacoTheme,
|
||||
options,
|
||||
hasDiff: !!(oldContent || newContent || diff),
|
||||
monacoTheme: getMonacoTheme(resolvedTheme),
|
||||
options: buildDiffEditorOptions({
|
||||
compact,
|
||||
wordWrap,
|
||||
modifiedReadOnly: compact || (!editable && !onRevert),
|
||||
onRevert,
|
||||
globalViewMode,
|
||||
foldUnchanged,
|
||||
lineHeight,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,10 @@ interface UseViewZonesParams {
|
||||
editingCommentId: string | null;
|
||||
setEditingComment: (id: string | null) => void;
|
||||
handleCommentSubmitRef: React.RefObject<(content: string) => void>;
|
||||
handleCommentSubmitAndRunRef?: React.RefObject<((content: string) => void) | undefined>;
|
||||
handleCommentDeleteRef: React.RefObject<(commentId: string) => void>;
|
||||
handleCommentUpdateRef: React.RefObject<(commentId: string, content: string) => void>;
|
||||
handleCommentRunRef?: React.RefObject<((comment: DiffComment) => void) | undefined>;
|
||||
clearModifiedGutter: () => void;
|
||||
clearOriginalGutter: () => void;
|
||||
setShowCommentForm: (v: boolean) => void;
|
||||
@@ -76,14 +78,55 @@ type CreateZonesParams = {
|
||||
editingCommentId: string | null;
|
||||
setEditingComment: (id: string | null) => void;
|
||||
handleCommentSubmitRef: React.RefObject<(content: string) => void>;
|
||||
handleCommentSubmitAndRunRef?: React.RefObject<((content: string) => void) | undefined>;
|
||||
handleCommentDeleteRef: React.RefObject<(commentId: string) => void>;
|
||||
handleCommentUpdateRef: React.RefObject<(commentId: string, content: string) => void>;
|
||||
handleCommentRunRef?: React.RefObject<((comment: DiffComment) => void) | undefined>;
|
||||
clearModifiedGutter: () => void;
|
||||
clearOriginalGutter: () => void;
|
||||
setShowCommentForm: (v: boolean) => void;
|
||||
setSelectedLineRange: (v: null) => void;
|
||||
};
|
||||
|
||||
function createCommentNode(
|
||||
comment: DiffComment,
|
||||
params: Pick<
|
||||
CreateZonesParams,
|
||||
| "setEditingComment"
|
||||
| "handleCommentUpdateRef"
|
||||
| "handleCommentDeleteRef"
|
||||
| "handleCommentRunRef"
|
||||
>,
|
||||
isEditing: boolean,
|
||||
) {
|
||||
if (isEditing) {
|
||||
return createElement(
|
||||
"div",
|
||||
{ className: "px-2 py-0.5" },
|
||||
createElement(CommentForm, {
|
||||
initialContent: comment.text,
|
||||
onSubmit: (c: string) => params.handleCommentUpdateRef.current?.(comment.id, c),
|
||||
onCancel: () => params.setEditingComment(null),
|
||||
isEditing: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return createElement(
|
||||
"div",
|
||||
{ className: "px-2 py-0.5" },
|
||||
createElement(CommentDisplay, {
|
||||
comment,
|
||||
onDelete: () => params.handleCommentDeleteRef.current?.(comment.id),
|
||||
onEdit: () => params.setEditingComment(comment.id),
|
||||
onRun: params.handleCommentRunRef?.current
|
||||
? () => params.handleCommentRunRef!.current?.(comment)
|
||||
: undefined,
|
||||
showCode: false,
|
||||
compact: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function createZones(params: CreateZonesParams): ViewZoneEntry[] {
|
||||
const {
|
||||
modifiedEditor,
|
||||
@@ -92,10 +135,8 @@ function createZones(params: CreateZonesParams): ViewZoneEntry[] {
|
||||
showCommentForm,
|
||||
selectedLineRange,
|
||||
editingCommentId,
|
||||
setEditingComment,
|
||||
handleCommentSubmitRef,
|
||||
handleCommentDeleteRef,
|
||||
handleCommentUpdateRef,
|
||||
handleCommentSubmitAndRunRef,
|
||||
clearModifiedGutter,
|
||||
clearOriginalGutter,
|
||||
setShowCommentForm,
|
||||
@@ -107,28 +148,7 @@ function createZones(params: CreateZonesParams): ViewZoneEntry[] {
|
||||
const side = comment.side === "deletions" ? "original" : "modified";
|
||||
const editor = side === "modified" ? modifiedEditor : originalEditor;
|
||||
const isEditing = editingCommentId === comment.id;
|
||||
const node = isEditing
|
||||
? createElement(
|
||||
"div",
|
||||
{ className: "px-2 py-0.5" },
|
||||
createElement(CommentForm, {
|
||||
initialContent: comment.text,
|
||||
onSubmit: (c: string) => handleCommentUpdateRef.current?.(comment.id, c),
|
||||
onCancel: () => setEditingComment(null),
|
||||
isEditing: true,
|
||||
}),
|
||||
)
|
||||
: createElement(
|
||||
"div",
|
||||
{ className: "px-2 py-0.5" },
|
||||
createElement(CommentDisplay, {
|
||||
comment,
|
||||
onDelete: () => handleCommentDeleteRef.current?.(comment.id),
|
||||
onEdit: () => setEditingComment(comment.id),
|
||||
showCode: false,
|
||||
compact: true,
|
||||
}),
|
||||
);
|
||||
const node = createCommentNode(comment, params, isEditing);
|
||||
addZone({
|
||||
targetEditor: editor,
|
||||
side,
|
||||
@@ -147,6 +167,9 @@ function createZones(params: CreateZonesParams): ViewZoneEntry[] {
|
||||
{ className: "px-2 py-1" },
|
||||
createElement(CommentForm, {
|
||||
onSubmit: (c: string) => handleCommentSubmitRef.current?.(c),
|
||||
onSubmitAndRun: handleCommentSubmitAndRunRef?.current
|
||||
? (c: string) => handleCommentSubmitAndRunRef.current?.(c)
|
||||
: undefined,
|
||||
onCancel: () => {
|
||||
setShowCommentForm(false);
|
||||
setSelectedLineRange(null);
|
||||
@@ -178,8 +201,10 @@ export function useViewZones({
|
||||
editingCommentId,
|
||||
setEditingComment,
|
||||
handleCommentSubmitRef,
|
||||
handleCommentSubmitAndRunRef,
|
||||
handleCommentDeleteRef,
|
||||
handleCommentUpdateRef,
|
||||
handleCommentRunRef,
|
||||
clearModifiedGutter,
|
||||
clearOriginalGutter,
|
||||
setShowCommentForm,
|
||||
@@ -205,8 +230,10 @@ export function useViewZones({
|
||||
editingCommentId,
|
||||
setEditingComment,
|
||||
handleCommentSubmitRef,
|
||||
handleCommentSubmitAndRunRef,
|
||||
handleCommentDeleteRef,
|
||||
handleCommentUpdateRef,
|
||||
handleCommentRunRef,
|
||||
clearModifiedGutter,
|
||||
clearOriginalGutter,
|
||||
setShowCommentForm,
|
||||
|
||||
@@ -31,6 +31,7 @@ interface UseDiffViewerCommentsOpts {
|
||||
compact: boolean;
|
||||
onCommentAdd?: (comment: DiffComment) => void;
|
||||
onCommentDelete?: (commentId: string) => void;
|
||||
onCommentRun?: (comment: DiffComment) => void;
|
||||
externalComments?: DiffComment[];
|
||||
onModifiedContentChange?: (filePath: string, content: string) => void;
|
||||
}
|
||||
@@ -43,6 +44,7 @@ export function useDiffViewerComments(opts: UseDiffViewerCommentsOpts) {
|
||||
compact,
|
||||
onCommentAdd,
|
||||
onCommentDelete,
|
||||
onCommentRun,
|
||||
externalComments,
|
||||
onModifiedContentChange,
|
||||
} = opts;
|
||||
@@ -196,6 +198,48 @@ export function useDiffViewerComments(opts: UseDiffViewerCommentsOpts) {
|
||||
],
|
||||
);
|
||||
|
||||
const handleCommentSubmitAndRun = useCallback(
|
||||
(content: string) => {
|
||||
if (!selectedLineRange || !onCommentRun) return;
|
||||
const comment = buildDiffComment({
|
||||
filePath: data.filePath,
|
||||
sessionId: sessionId || "",
|
||||
startLine: selectedLineRange.start,
|
||||
endLine: selectedLineRange.end,
|
||||
side: (selectedLineRange.side || "additions") as DiffComment["side"],
|
||||
text: content,
|
||||
});
|
||||
if (onCommentAdd && externalComments !== undefined) {
|
||||
onCommentAdd(comment);
|
||||
} else if (sessionId) {
|
||||
addComment(
|
||||
{
|
||||
start: selectedLineRange.start,
|
||||
end: selectedLineRange.end,
|
||||
side: selectedLineRange.side as "additions" | "deletions",
|
||||
},
|
||||
content,
|
||||
);
|
||||
}
|
||||
onCommentRun(comment);
|
||||
setShowCommentForm(false);
|
||||
setSelectedLineRange(null);
|
||||
clearModifiedGutter();
|
||||
clearOriginalGutter();
|
||||
},
|
||||
[
|
||||
selectedLineRange,
|
||||
sessionId,
|
||||
data.filePath,
|
||||
addComment,
|
||||
onCommentAdd,
|
||||
onCommentRun,
|
||||
externalComments,
|
||||
clearModifiedGutter,
|
||||
clearOriginalGutter,
|
||||
],
|
||||
);
|
||||
|
||||
const { handleCommentDelete, handleCommentUpdate } = useCommentActions({
|
||||
removeComment,
|
||||
updateComment,
|
||||
@@ -209,6 +253,10 @@ export function useDiffViewerComments(opts: UseDiffViewerCommentsOpts) {
|
||||
useEffect(() => {
|
||||
handleCommentSubmitRef.current = handleCommentSubmit;
|
||||
}, [handleCommentSubmit]);
|
||||
const handleCommentSubmitAndRunRef = useRef(onCommentRun ? handleCommentSubmitAndRun : undefined);
|
||||
useEffect(() => {
|
||||
handleCommentSubmitAndRunRef.current = onCommentRun ? handleCommentSubmitAndRun : undefined;
|
||||
}, [handleCommentSubmitAndRun, onCommentRun]);
|
||||
const handleCommentDeleteRef = useRef(handleCommentDelete);
|
||||
useEffect(() => {
|
||||
handleCommentDeleteRef.current = handleCommentDelete;
|
||||
@@ -217,6 +265,10 @@ export function useDiffViewerComments(opts: UseDiffViewerCommentsOpts) {
|
||||
useEffect(() => {
|
||||
handleCommentUpdateRef.current = handleCommentUpdate;
|
||||
}, [handleCommentUpdate]);
|
||||
const handleCommentRunRef = useRef(onCommentRun);
|
||||
useEffect(() => {
|
||||
handleCommentRunRef.current = onCommentRun;
|
||||
}, [onCommentRun]);
|
||||
|
||||
useViewZones({
|
||||
modifiedEditor,
|
||||
@@ -227,8 +279,10 @@ export function useDiffViewerComments(opts: UseDiffViewerCommentsOpts) {
|
||||
editingCommentId,
|
||||
setEditingComment,
|
||||
handleCommentSubmitRef,
|
||||
handleCommentSubmitAndRunRef,
|
||||
handleCommentDeleteRef,
|
||||
handleCommentUpdateRef,
|
||||
handleCommentRunRef,
|
||||
clearModifiedGutter,
|
||||
clearOriginalGutter,
|
||||
setShowCommentForm,
|
||||
|
||||
@@ -3,11 +3,14 @@ import type { OnMount, OnChange } from "@monaco-editor/react";
|
||||
import type { editor as monacoEditor, IDisposable } from "monaco-editor";
|
||||
import { useCommentsStore } from "@/lib/state/slices/comments";
|
||||
import { useDiffFileComments } from "@/hooks/domains/comments/use-diff-comments";
|
||||
import { useRunComment } from "@/hooks/domains/comments/use-run-comment";
|
||||
import { buildDiffComment, useCommentedLines } from "@/lib/diff/comment-utils";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useCommandPanelOpen } from "@/lib/commands/command-registry";
|
||||
import { useGutterComments } from "@/hooks/use-gutter-comments";
|
||||
import { consumePendingCursorPosition } from "@/hooks/use-file-editors";
|
||||
import type { DiffComment } from "@/lib/diff/types";
|
||||
|
||||
export type FormZoneRange = {
|
||||
startLine: number;
|
||||
@@ -331,20 +334,31 @@ export function useMonacoEditorComments(opts: UseMonacoEditorStateOpts) {
|
||||
[currentSelection],
|
||||
);
|
||||
|
||||
const handleCommentSubmit = useCallback(
|
||||
(annotation: string) => {
|
||||
if (!formZoneRange || !sessionId) return;
|
||||
addComment(
|
||||
buildDiffComment({
|
||||
filePath: path,
|
||||
sessionId,
|
||||
startLine: formZoneRange.startLine,
|
||||
endLine: formZoneRange.endLine,
|
||||
side: "additions",
|
||||
text: annotation,
|
||||
codeContent: formZoneRange.codeContent,
|
||||
}),
|
||||
);
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const activeSession = useAppStore((state) => {
|
||||
const sid = state.tasks.activeSessionId;
|
||||
return sid ? (state.taskSessions.items[sid] ?? null) : null;
|
||||
});
|
||||
const isAgentBusy = activeSession?.state === "STARTING" || activeSession?.state === "RUNNING";
|
||||
const { runComment } = useRunComment({
|
||||
sessionId: sessionId ?? null,
|
||||
taskId: activeTaskId ?? null,
|
||||
isAgentBusy,
|
||||
});
|
||||
|
||||
const createCommentFromForm = useCallback(
|
||||
(annotation: string): DiffComment | null => {
|
||||
if (!formZoneRange || !sessionId) return null;
|
||||
const comment = buildDiffComment({
|
||||
filePath: path,
|
||||
sessionId,
|
||||
startLine: formZoneRange.startLine,
|
||||
endLine: formZoneRange.endLine,
|
||||
side: "additions",
|
||||
text: annotation,
|
||||
codeContent: formZoneRange.codeContent,
|
||||
});
|
||||
addComment(comment);
|
||||
setFormZoneRange(null);
|
||||
clearGutterSelection();
|
||||
const editor = editorRef.current;
|
||||
@@ -358,12 +372,63 @@ export function useMonacoEditorComments(opts: UseMonacoEditorStateOpts) {
|
||||
endColumn: pos.column,
|
||||
});
|
||||
}
|
||||
toast({
|
||||
title: "Comment added",
|
||||
description: "Your comment will be sent with your next message.",
|
||||
});
|
||||
return comment;
|
||||
},
|
||||
[formZoneRange, sessionId, path, addComment, clearGutterSelection, toast],
|
||||
[formZoneRange, sessionId, path, addComment, clearGutterSelection],
|
||||
);
|
||||
|
||||
const handleCommentSubmit = useCallback(
|
||||
(annotation: string) => {
|
||||
const comment = createCommentFromForm(annotation);
|
||||
if (comment) {
|
||||
toast({
|
||||
title: "Comment added",
|
||||
description: "Your comment will be sent with your next message.",
|
||||
});
|
||||
}
|
||||
},
|
||||
[createCommentFromForm, toast],
|
||||
);
|
||||
|
||||
const handleCommentSubmitAndRun = useCallback(
|
||||
async (annotation: string) => {
|
||||
const comment = createCommentFromForm(annotation);
|
||||
if (comment) {
|
||||
try {
|
||||
await runComment(comment);
|
||||
toast({
|
||||
title: "Comment sent",
|
||||
description: isAgentBusy ? "Queued for the agent." : "Sent to the agent.",
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: "Failed to send comment",
|
||||
description: "Please try again.",
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[createCommentFromForm, runComment, isAgentBusy, toast],
|
||||
);
|
||||
|
||||
const handleCommentRun = useCallback(
|
||||
async (comment: DiffComment) => {
|
||||
try {
|
||||
await runComment(comment);
|
||||
toast({
|
||||
title: "Comment sent",
|
||||
description: isAgentBusy ? "Queued for the agent." : "Sent to the agent.",
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: "Failed to send comment",
|
||||
description: "Please try again.",
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
[runComment, isAgentBusy, toast],
|
||||
);
|
||||
|
||||
const handleDeleteComment = useCallback(
|
||||
@@ -388,12 +453,20 @@ export function useMonacoEditorComments(opts: UseMonacoEditorStateOpts) {
|
||||
useEffect(() => {
|
||||
handleCommentSubmitRef.current = handleCommentSubmit;
|
||||
}, [handleCommentSubmit]);
|
||||
const handleCommentSubmitAndRunRef = useRef(handleCommentSubmitAndRun);
|
||||
useEffect(() => {
|
||||
handleCommentSubmitAndRunRef.current = handleCommentSubmitAndRun;
|
||||
}, [handleCommentSubmitAndRun]);
|
||||
useEffect(() => {
|
||||
handleCommentDeleteRef.current = handleDeleteComment;
|
||||
}, [handleDeleteComment]);
|
||||
useEffect(() => {
|
||||
handleCommentUpdateRef.current = handleUpdateComment;
|
||||
}, [handleUpdateComment]);
|
||||
const handleCommentRunRef = useRef(handleCommentRun);
|
||||
useEffect(() => {
|
||||
handleCommentRunRef.current = handleCommentRun;
|
||||
}, [handleCommentRun]);
|
||||
|
||||
return {
|
||||
wrapEnabled,
|
||||
@@ -415,7 +488,9 @@ export function useMonacoEditorComments(opts: UseMonacoEditorStateOpts) {
|
||||
handleChange,
|
||||
handleFloatingButtonClick,
|
||||
handleCommentSubmitRef,
|
||||
handleCommentSubmitAndRunRef,
|
||||
handleCommentDeleteRef,
|
||||
handleCommentUpdateRef,
|
||||
handleCommentRunRef,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ import { FileActionsDropdown } from "@/components/editors/file-actions-dropdown"
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { requestFileContent, updateFileContent } from "@/lib/ws/workspace-files";
|
||||
import { generateUnifiedDiff, calculateHash } from "@/lib/utils/file-diff";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useRunComment } from "@/hooks/domains/comments/use-run-comment";
|
||||
import type { DiffComment } from "@/lib/diff/types";
|
||||
import type { ReviewFile } from "./types";
|
||||
|
||||
type ReviewDiffListProps = {
|
||||
@@ -246,6 +249,21 @@ function FileDiffHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function useCommentRunHandler(sessionId: string) {
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const activeSession = useAppStore((state) => {
|
||||
const sid = state.tasks.activeSessionId;
|
||||
return sid ? (state.taskSessions.items[sid] ?? null) : null;
|
||||
});
|
||||
const isAgentBusy = activeSession?.state === "STARTING" || activeSession?.state === "RUNNING";
|
||||
const { runComment } = useRunComment({
|
||||
sessionId,
|
||||
taskId: activeTaskId ?? null,
|
||||
isAgentBusy,
|
||||
});
|
||||
return useCallback((comment: DiffComment) => runComment(comment), [runComment]);
|
||||
}
|
||||
|
||||
async function revertBlock(sessionId: string, filePath: string, info: RevertBlockInfo) {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
@@ -326,6 +344,8 @@ function FileDiffSection({
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
const handleCommentRun = useCommentRunHandler(sessionId);
|
||||
|
||||
return (
|
||||
<div ref={sectionRef} className="border-b border-border">
|
||||
<div ref={scrollSentinelRef} className="h-0" />
|
||||
@@ -351,6 +371,7 @@ function FileDiffSection({
|
||||
enableComments
|
||||
enableAcceptReject
|
||||
onRevertBlock={handleRevertBlock}
|
||||
onCommentRun={handleCommentRun}
|
||||
sessionId={sessionId}
|
||||
wordWrap={wordWrap}
|
||||
enableExpansion={true}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { IconPlus, IconGripHorizontal } from "@tabler/icons-react";
|
||||
import { IconPlus, IconGripHorizontal, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type EditorCommentPopoverProps = {
|
||||
@@ -11,6 +12,7 @@ type EditorCommentPopoverProps = {
|
||||
lineRange: { start: number; end: number };
|
||||
position: { x: number; y: number };
|
||||
onSubmit: (comment: string) => void;
|
||||
onSubmitAndRun?: (comment: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
@@ -91,26 +93,34 @@ function PopoverBody({
|
||||
comment,
|
||||
setComment,
|
||||
handleSubmit,
|
||||
handleSubmitAndRun,
|
||||
textareaRef,
|
||||
}: {
|
||||
selectedText: string;
|
||||
comment: string;
|
||||
setComment: (v: string) => void;
|
||||
handleSubmit: () => void;
|
||||
handleSubmitAndRun?: () => void;
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
}) {
|
||||
const previewText =
|
||||
selectedText.length > 100 ? selectedText.slice(0, 100).trim() + "…" : selectedText;
|
||||
const isDisabled = !comment.trim();
|
||||
const isMac = typeof navigator !== "undefined" && navigator.platform?.includes("Mac");
|
||||
const modKey = isMac ? "\u2318" : "Ctrl";
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
if (e.shiftKey && handleSubmitAndRun) {
|
||||
handleSubmitAndRun();
|
||||
} else {
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
[handleSubmit, handleSubmitAndRun],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -127,16 +137,48 @@ function PopoverBody({
|
||||
className="min-h-[72px] resize-none text-sm border-border/50 focus:border-primary/50"
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground/70">{"\u2318"}+Enter to add</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isDisabled}
|
||||
className="gap-1.5 cursor-pointer"
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
Add comment
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
{modKey}+Enter to add{handleSubmitAndRun ? `, ${modKey}+Shift+Enter to run` : ""}
|
||||
</span>
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div className="inline-flex">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={handleSubmitAndRun ? "outline" : "default"}
|
||||
onClick={handleSubmit}
|
||||
disabled={isDisabled}
|
||||
className={`gap-1.5 cursor-pointer ${handleSubmitAndRun ? "rounded-r-none border-r-0" : ""}`}
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
Add
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Save comment for review ({modKey}+Enter)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{handleSubmitAndRun && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmitAndRun}
|
||||
disabled={isDisabled}
|
||||
className="gap-1.5 rounded-l-none cursor-pointer"
|
||||
>
|
||||
<IconPlayerPlay className="h-3.5 w-3.5" />
|
||||
Run
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Save and send to agent ({modKey}+Shift+Enter)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -147,6 +189,7 @@ export function EditorCommentPopover({
|
||||
lineRange,
|
||||
position,
|
||||
onSubmit,
|
||||
onSubmitAndRun,
|
||||
onClose,
|
||||
}: EditorCommentPopoverProps) {
|
||||
const [comment, setComment] = useState("");
|
||||
@@ -164,6 +207,11 @@ export function EditorCommentPopover({
|
||||
onSubmit(comment.trim());
|
||||
}, [comment, onSubmit]);
|
||||
|
||||
const handleSubmitAndRun = useCallback(() => {
|
||||
if (!comment.trim() || !onSubmitAndRun) return;
|
||||
onSubmitAndRun(comment.trim());
|
||||
}, [comment, onSubmitAndRun]);
|
||||
|
||||
const lineRangeText =
|
||||
lineRange.start === lineRange.end
|
||||
? `Line ${lineRange.start}`
|
||||
@@ -192,6 +240,7 @@ export function EditorCommentPopover({
|
||||
comment={comment}
|
||||
setComment={setComment}
|
||||
handleSubmit={handleSubmit}
|
||||
handleSubmitAndRun={onSubmitAndRun ? handleSubmitAndRun : undefined}
|
||||
textareaRef={textareaRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { IconPlus, IconTrash, IconGripHorizontal } from "@tabler/icons-react";
|
||||
import { IconPlus, IconTrash, IconGripHorizontal, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SelectionPosition = {
|
||||
@@ -16,6 +17,7 @@ type PlanSelectionPopoverProps = {
|
||||
selectedText: string;
|
||||
position: SelectionPosition;
|
||||
onAdd: (comment: string, selectedText: string) => void;
|
||||
onAddAndRun?: (comment: string, selectedText: string) => void;
|
||||
onClose: () => void;
|
||||
editingComment?: string;
|
||||
onDelete?: () => void;
|
||||
@@ -111,6 +113,7 @@ function usePopoverComposer(
|
||||
selectedText: string,
|
||||
onAdd: (comment: string, selectedText: string) => void,
|
||||
onClose: () => void,
|
||||
onAddAndRun?: (comment: string, selectedText: string) => void,
|
||||
) {
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!comment.trim()) return;
|
||||
@@ -118,18 +121,29 @@ function usePopoverComposer(
|
||||
onClose();
|
||||
}, [comment, onAdd, selectedText, onClose]);
|
||||
|
||||
const handleSubmitAndRun = useCallback(() => {
|
||||
if (!comment.trim() || !onAddAndRun) return;
|
||||
onAddAndRun(comment.trim(), selectedText);
|
||||
onClose();
|
||||
}, [comment, onAddAndRun, selectedText, onClose]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
if (e.shiftKey && onAddAndRun) {
|
||||
handleSubmitAndRun();
|
||||
} else {
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
[handleSubmit, handleSubmitAndRun, onAddAndRun],
|
||||
);
|
||||
|
||||
return {
|
||||
handleSubmit,
|
||||
handleSubmitAndRun,
|
||||
handleKeyDown,
|
||||
isDisabled: !comment.trim(),
|
||||
previewText:
|
||||
@@ -137,10 +151,85 @@ function usePopoverComposer(
|
||||
};
|
||||
}
|
||||
|
||||
function PopoverActions({
|
||||
isEditing,
|
||||
isDisabled,
|
||||
onSubmit,
|
||||
onSubmitAndRun,
|
||||
onDelete,
|
||||
}: {
|
||||
isEditing: boolean;
|
||||
isDisabled: boolean;
|
||||
onSubmit: () => void;
|
||||
onSubmitAndRun?: () => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
⌘+Enter to {isEditing ? "update" : "add"}
|
||||
{onSubmitAndRun && !isEditing ? ", ⌘+Shift+Enter to run" : ""}
|
||||
</span>
|
||||
{isEditing && onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDelete}
|
||||
className="h-6 px-1.5 text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
>
|
||||
<IconTrash className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div className="inline-flex">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={onSubmitAndRun && !isEditing ? "outline" : "default"}
|
||||
onClick={onSubmit}
|
||||
disabled={isDisabled}
|
||||
className={`h-7 gap-1 text-xs cursor-pointer ${onSubmitAndRun && !isEditing ? "rounded-r-none border-r-0" : ""}`}
|
||||
>
|
||||
<IconPlus className="h-3 w-3" />
|
||||
{isEditing ? "Update" : "Add"}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{isEditing ? "Update comment" : "Save comment for review"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onSubmitAndRun && !isEditing && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSubmitAndRun}
|
||||
disabled={isDisabled}
|
||||
className="h-7 gap-1 rounded-l-none text-xs cursor-pointer"
|
||||
>
|
||||
<IconPlayerPlay className="h-3 w-3" />
|
||||
Run
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Save and send to agent</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanSelectionPopover({
|
||||
selectedText,
|
||||
position,
|
||||
onAdd,
|
||||
onAddAndRun,
|
||||
onClose,
|
||||
editingComment,
|
||||
onDelete,
|
||||
@@ -154,15 +243,18 @@ export function PlanSelectionPopover({
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
usePopoverDismiss(onClose, popoverRef);
|
||||
const { handleSubmit, handleKeyDown, isDisabled, previewText } = usePopoverComposer(
|
||||
comment,
|
||||
selectedText,
|
||||
onAdd,
|
||||
onClose,
|
||||
);
|
||||
const effectiveOnAddAndRun = editingComment ? undefined : onAddAndRun;
|
||||
const { handleSubmit, handleSubmitAndRun, handleKeyDown, isDisabled, previewText } =
|
||||
usePopoverComposer(comment, selectedText, onAdd, onClose, effectiveOnAddAndRun);
|
||||
const { left, top } = computePopoverPosition(position);
|
||||
|
||||
// Portal to document.body to escape dockview's CSS transform containing block
|
||||
const handleDelete = onDelete
|
||||
? () => {
|
||||
onDelete();
|
||||
onClose();
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
@@ -170,27 +262,18 @@ export function PlanSelectionPopover({
|
||||
"fixed z-50 rounded-xl border border-border/50 bg-popover/95 backdrop-blur-sm shadow-xl",
|
||||
"animate-in fade-in-0 zoom-in-95 duration-150",
|
||||
)}
|
||||
style={{
|
||||
width: POPOVER_WIDTH,
|
||||
left: left + offset.dx,
|
||||
top: top + offset.dy,
|
||||
}}
|
||||
style={{ width: POPOVER_WIDTH, left: left + offset.dx, top: top + offset.dy }}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
onMouseDown={onMouseDown}
|
||||
className="flex items-center justify-center py-1.5 cursor-grab active:cursor-grabbing border-b border-border/30"
|
||||
>
|
||||
<IconGripHorizontal className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
{/* Text preview */}
|
||||
<p className="mb-2 text-xs text-muted-foreground line-clamp-2 leading-relaxed italic">
|
||||
“{previewText}”
|
||||
</p>
|
||||
|
||||
{/* Comment input */}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={comment}
|
||||
@@ -199,37 +282,13 @@ export function PlanSelectionPopover({
|
||||
placeholder="Add your comment or instruction..."
|
||||
className="min-h-[60px] resize-none text-sm border-border/50 focus:border-primary/50"
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
⌘+Enter to {editingComment ? "update" : "add"}
|
||||
</span>
|
||||
{editingComment && onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
onClose();
|
||||
}}
|
||||
className="h-6 px-1.5 text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
>
|
||||
<IconTrash className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isDisabled}
|
||||
className="h-7 gap-1 text-xs cursor-pointer"
|
||||
>
|
||||
<IconPlus className="h-3 w-3" />
|
||||
{editingComment ? "Update" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
<PopoverActions
|
||||
isEditing={!!editingComment}
|
||||
isDisabled={isDisabled}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmitAndRun={effectiveOnAddAndRun ? handleSubmitAndRun : undefined}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useTaskPlan } from "@/hooks/domains/session/use-task-plan";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { PlanSelectionPopover } from "./plan-selection-popover";
|
||||
import { usePlanComments } from "@/hooks/domains/comments/use-plan-comments";
|
||||
import { useRunComment } from "@/hooks/domains/comments/use-run-comment";
|
||||
import type { PlanComment } from "@/lib/state/slices/comments";
|
||||
import type {
|
||||
TextSelection,
|
||||
CommentForEditor,
|
||||
@@ -191,6 +193,8 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({
|
||||
<PlanSelectionPopoverWrapper
|
||||
textSelection={textSelection}
|
||||
activeSessionId={activeSessionId}
|
||||
taskId={taskId}
|
||||
isAgentBusy={isAgentBusy}
|
||||
commentState={commentState}
|
||||
editorRef={editorInstanceRef}
|
||||
onClose={selectionState.handleSelectionClose}
|
||||
@@ -199,26 +203,44 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({
|
||||
);
|
||||
});
|
||||
|
||||
function removeCommentMark(editor: Editor | null, commentId: string) {
|
||||
if (!editor) return;
|
||||
const markType = editor.state.schema.marks.commentMark;
|
||||
if (!markType) return;
|
||||
const { tr } = editor.state;
|
||||
tr.removeMark(0, editor.state.doc.content.size, markType.create({ commentId }));
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
|
||||
/** Conditional selection popover for adding/editing comments */
|
||||
function PlanSelectionPopoverWrapper({
|
||||
textSelection,
|
||||
activeSessionId,
|
||||
taskId,
|
||||
isAgentBusy,
|
||||
commentState,
|
||||
editorRef,
|
||||
onClose,
|
||||
}: {
|
||||
textSelection: TextSelection | null;
|
||||
activeSessionId: string | null | undefined;
|
||||
taskId: string | null;
|
||||
isAgentBusy: boolean;
|
||||
commentState: ReturnType<typeof usePlanComments>;
|
||||
editorRef: React.RefObject<Editor | null>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const handleAdd = useCallback(
|
||||
const { runComment } = useRunComment({
|
||||
sessionId: activeSessionId ?? null,
|
||||
taskId,
|
||||
isAgentBusy,
|
||||
});
|
||||
|
||||
const addCommentAndApplyMark = useCallback(
|
||||
(comment: string, selectedText: string) => {
|
||||
const from = textSelection?.from;
|
||||
const to = textSelection?.to;
|
||||
const id = commentState.handleAddComment(comment, selectedText, from, to);
|
||||
// Apply the mark to the editor at the selection position
|
||||
const editor = editorRef.current;
|
||||
if (id && editor && from != null && to != null) {
|
||||
editor
|
||||
@@ -227,10 +249,38 @@ function PlanSelectionPopoverWrapper({
|
||||
.setMark("commentMark", { commentId: id })
|
||||
.run();
|
||||
}
|
||||
return id;
|
||||
},
|
||||
[commentState, textSelection, editorRef],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(comment: string, selectedText: string) => {
|
||||
addCommentAndApplyMark(comment, selectedText);
|
||||
},
|
||||
[addCommentAndApplyMark],
|
||||
);
|
||||
|
||||
const handleAddAndRun = useCallback(
|
||||
(comment: string, selectedText: string) => {
|
||||
const id = addCommentAndApplyMark(comment, selectedText);
|
||||
if (!id || !activeSessionId) return;
|
||||
const newComment: PlanComment = {
|
||||
id,
|
||||
sessionId: activeSessionId,
|
||||
source: "plan",
|
||||
text: comment,
|
||||
selectedText,
|
||||
from: textSelection?.from,
|
||||
to: textSelection?.to,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: "pending",
|
||||
};
|
||||
runComment(newComment);
|
||||
},
|
||||
[addCommentAndApplyMark, activeSessionId, runComment, textSelection],
|
||||
);
|
||||
|
||||
if (!textSelection || !activeSessionId) return null;
|
||||
const editingComment = commentState.editingCommentId
|
||||
? commentState.comments.find((c) => c.id === commentState.editingCommentId)?.text
|
||||
@@ -238,16 +288,7 @@ function PlanSelectionPopoverWrapper({
|
||||
const onDelete = commentState.editingCommentId
|
||||
? () => {
|
||||
const id = commentState.editingCommentId!;
|
||||
// Remove the mark from the editor
|
||||
const editor = editorRef.current;
|
||||
if (editor) {
|
||||
const markType = editor.state.schema.marks.commentMark;
|
||||
if (markType) {
|
||||
const { tr } = editor.state;
|
||||
tr.removeMark(0, editor.state.doc.content.size, markType.create({ commentId: id }));
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
}
|
||||
removeCommentMark(editorRef.current, id);
|
||||
commentState.handleDeleteComment(id);
|
||||
}
|
||||
: undefined;
|
||||
@@ -256,6 +297,7 @@ function PlanSelectionPopoverWrapper({
|
||||
selectedText={textSelection.text}
|
||||
position={textSelection.position}
|
||||
onAdd={handleAdd}
|
||||
onAddAndRun={editingComment ? undefined : handleAddAndRun}
|
||||
onClose={onClose}
|
||||
editingComment={editingComment}
|
||||
onDelete={onDelete}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback } from "react";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { appendToQueue } from "@/lib/api/domains/queue-api";
|
||||
import { useCommentsStore } from "@/lib/state/slices/comments";
|
||||
import {
|
||||
formatReviewCommentsAsMarkdown,
|
||||
formatPlanCommentsAsMarkdown,
|
||||
formatPRFeedbackAsMarkdown,
|
||||
} from "@/lib/state/slices/comments/format";
|
||||
import type {
|
||||
Comment,
|
||||
DiffComment,
|
||||
PlanComment,
|
||||
FileEditorComment,
|
||||
PRFeedbackComment,
|
||||
} from "@/lib/state/slices/comments";
|
||||
|
||||
/**
|
||||
* Format a single comment into markdown suitable for sending to the agent.
|
||||
*/
|
||||
function formatSingleComment(comment: Comment): string {
|
||||
switch (comment.source) {
|
||||
case "diff":
|
||||
return formatReviewCommentsAsMarkdown([comment as DiffComment]);
|
||||
case "plan":
|
||||
return formatPlanCommentsAsMarkdown([comment as PlanComment]);
|
||||
case "pr-feedback":
|
||||
return formatPRFeedbackAsMarkdown([comment as PRFeedbackComment]);
|
||||
case "file-editor": {
|
||||
const fc = comment as FileEditorComment;
|
||||
const lines: string[] = ["### File Comment", ""];
|
||||
let loc = fc.filePath;
|
||||
if (fc.startLine && fc.endLine && fc.endLine !== fc.startLine) {
|
||||
loc = `${fc.filePath}:${fc.startLine}-${fc.endLine}`;
|
||||
} else if (fc.startLine) {
|
||||
loc = `${fc.filePath}:${fc.startLine}`;
|
||||
}
|
||||
lines.push(`**${loc}**`);
|
||||
if (fc.selectedText) {
|
||||
lines.push("```");
|
||||
lines.push(fc.selectedText);
|
||||
lines.push("```");
|
||||
}
|
||||
lines.push(`> ${fc.text}`);
|
||||
lines.push("", "---", "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type UseRunCommentParams = {
|
||||
sessionId: string | null;
|
||||
taskId: string | null;
|
||||
isAgentBusy: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that provides a function to immediately send a comment to the agent.
|
||||
*
|
||||
* If the agent is idle, sends as a direct message.
|
||||
* If the agent is busy, appends to the queued message (or creates a new one).
|
||||
*/
|
||||
export function useRunComment({ sessionId, taskId, isAgentBusy }: UseRunCommentParams) {
|
||||
const markCommentsSent = useCommentsStore((s) => s.markCommentsSent);
|
||||
|
||||
const runComment = useCallback(
|
||||
async (comment: Comment) => {
|
||||
if (!sessionId || !taskId) return;
|
||||
|
||||
const content = formatSingleComment(comment);
|
||||
|
||||
try {
|
||||
if (isAgentBusy) {
|
||||
await appendToQueue({
|
||||
session_id: sessionId,
|
||||
task_id: taskId,
|
||||
content,
|
||||
});
|
||||
} else {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
|
||||
await client.request(
|
||||
"message.add",
|
||||
{
|
||||
task_id: taskId,
|
||||
session_id: sessionId,
|
||||
content,
|
||||
has_review_comments: true,
|
||||
},
|
||||
10000,
|
||||
);
|
||||
}
|
||||
|
||||
markCommentsSent([comment.id]);
|
||||
} catch (error) {
|
||||
console.error("Failed to send comment to agent:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[sessionId, taskId, isAgentBusy, markCommentsSent],
|
||||
);
|
||||
|
||||
return { runComment };
|
||||
}
|
||||
@@ -43,6 +43,22 @@ export async function getQueueStatus(sessionId: string): Promise<QueueStatus> {
|
||||
return client.request<QueueStatus>("message.queue.get", { session_id: sessionId });
|
||||
}
|
||||
|
||||
// Append content to an existing queued message, or create a new one if none exists
|
||||
export async function appendToQueue(params: {
|
||||
session_id: string;
|
||||
task_id: string;
|
||||
content: string;
|
||||
model?: string;
|
||||
plan_mode?: boolean;
|
||||
}): Promise<QueueStatus> {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) {
|
||||
throw new Error(WS_CLIENT_UNAVAILABLE);
|
||||
}
|
||||
|
||||
return client.request<QueueStatus>("message.queue.append", params);
|
||||
}
|
||||
|
||||
// Update queued message content (for arrow up editing)
|
||||
export async function updateQueuedMessage(
|
||||
sessionId: string,
|
||||
|
||||
Reference in New Issue
Block a user