feat(comments): add "Add + Run" button to send comments directly to agent (#430)

This commit is contained in:
Carlos Florêncio
2026-03-13 10:44:32 +00:00
committed by GitHub
parent f1da9644f4
commit 4b6ff47402
25 changed files with 1181 additions and 312 deletions
@@ -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)
+1
View File
@@ -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
+78 -52
View File
@@ -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>
);
+102 -32
View File
@@ -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;
+5
View File
@@ -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">
&ldquo;{previewText}&rdquo;
</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,
+54 -12
View File
@@ -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 };
}
+16
View File
@@ -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,