diff --git a/apps/backend/internal/orchestrator/handlers/queue_handlers.go b/apps/backend/internal/orchestrator/handlers/queue_handlers.go
index a59661c856..32a0eea08f 100644
--- a/apps/backend/internal/orchestrator/handlers/queue_handlers.go
+++ b/apps/backend/internal/orchestrator/handlers/queue_handlers.go
@@ -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)
+}
diff --git a/apps/backend/internal/orchestrator/handlers/queue_handlers_test.go b/apps/backend/internal/orchestrator/handlers/queue_handlers_test.go
index 648df69d54..5a862c218a 100644
--- a/apps/backend/internal/orchestrator/handlers/queue_handlers_test.go
+++ b/apps/backend/internal/orchestrator/handlers/queue_handlers_test.go
@@ -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)
diff --git a/apps/backend/internal/orchestrator/messagequeue/service.go b/apps/backend/internal/orchestrator/messagequeue/service.go
index 5cb70cb1d5..7c41e9f0c9 100644
--- a/apps/backend/internal/orchestrator/messagequeue/service.go
+++ b/apps/backend/internal/orchestrator/messagequeue/service.go
@@ -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()
diff --git a/apps/backend/internal/orchestrator/messagequeue/service_test.go b/apps/backend/internal/orchestrator/messagequeue/service_test.go
index 177f09a7bd..e72131304f 100644
--- a/apps/backend/internal/orchestrator/messagequeue/service_test.go
+++ b/apps/backend/internal/orchestrator/messagequeue/service_test.go
@@ -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)
diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go
index 15d0d01c6c..333a177379 100644
--- a/apps/backend/pkg/websocket/actions.go
+++ b/apps/backend/pkg/websocket/actions.go
@@ -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
diff --git a/apps/web/components/diff/comment-display.tsx b/apps/web/components/diff/comment-display.tsx
index a4a15b142c..05dd33dcc7 100644
--- a/apps/web/components/diff/comment-display.tsx
+++ b/apps/web/components/diff/comment-display.tsx
@@ -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 && (
+
+ )}
+ {onEdit && (
+
+ )}
+ {onDelete && (
+
+ )}
+ >
+ );
+}
+
export function CommentDisplay({
comment,
onDelete,
onEdit,
+ onRun,
showCode = false,
compact = false,
}: CommentDisplayProps) {
@@ -40,32 +109,13 @@ export function CommentDisplay({
{comment.text}
- {onEdit && (
-
- )}
- {onDelete && (
-
- )}
+
);
@@ -73,7 +123,6 @@ export function CommentDisplay({
return (
- {/* Header */}
@@ -83,37 +132,14 @@ export function CommentDisplay({
- {onEdit && (
-
- )}
- {onDelete && (
-
- )}
+
-
- {/* Code preview */}
{showCode && comment.codeContent && (
{comment.codeContent}
)}
-
- {/* Comment text */}
{comment.text}
);
diff --git a/apps/web/components/diff/comment-form.tsx b/apps/web/components/diff/comment-form.tsx
index 8bd00a79e1..e00e213f36 100644
--- a/apps/web/components/diff/comment-form.tsx
+++ b/apps/web/components/diff/comment-form.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+ Save comment for review ({modKey}+Enter)
+
+
+ {showRunButton && onSubmitAndRun && (
+
+
+
+
+
+ Save and send to agent ({modKey}+Shift+Enter)
+
+
+ )}
+
+
+
+ );
+}
+
export function CommentForm({
initialContent = "",
onSubmit,
onCancel,
+ onSubmitAndRun,
isEditing = false,
autoFocus = true,
}: CommentFormProps) {
@@ -29,9 +98,7 @@ export function CommentForm({
const textareaRef = useRef(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 (
- {typeof navigator !== "undefined" && navigator.platform?.includes("Mac") ? "⌘" : "Ctrl"}
- +Enter to submit
+ {modKey}+Enter to add{onSubmitAndRun ? `, ${modKey}+Shift+Enter to run` : ""}
-
-
-
-
+
);
diff --git a/apps/web/components/diff/diff-viewer-resolver.tsx b/apps/web/components/diff/diff-viewer-resolver.tsx
index 499114a746..8c29b18fc9 100644
--- a/apps/web/components/diff/diff-viewer-resolver.tsx
+++ b/apps/web/components/diff/diff-viewer-resolver.tsx
@@ -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;
diff --git a/apps/web/components/diff/diff-viewer.tsx b/apps/web/components/diff/diff-viewer.tsx
index 48b6727577..e1f216a194 100644
--- a/apps/web/components/diff/diff-viewer.tsx
+++ b/apps/web/components/diff/diff-viewer.tsx
@@ -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,
diff --git a/apps/web/components/diff/file-diff-viewer.tsx b/apps/web/components/diff/file-diff-viewer.tsx
index 1149cd475c..2b592f780b 100644
--- a/apps/web/components/diff/file-diff-viewer.tsx
+++ b/apps/web/components/diff/file-diff-viewer.tsx
@@ -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}
diff --git a/apps/web/components/diff/use-diff-annotation-renderer.tsx b/apps/web/components/diff/use-diff-annotation-renderer.tsx
index b722de8d46..f70fa96469 100644
--- a/apps/web/components/diff/use-diff-annotation-renderer.tsx
+++ b/apps/web/components/diff/use-diff-annotation-renderer.tsx
@@ -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) {
{
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}
/>
@@ -100,8 +106,10 @@ export function useAnnotationRenderer(opts: UseAnnotationRendererOpts) {
[
setEditingComment,
handleCommentDelete,
+ handleCommentRun,
handleCommentUpdate,
handleCommentSubmit,
+ handleCommentSubmitAndRun,
handleRevertBlock,
onButtonEnter,
onButtonLeave,
diff --git a/apps/web/components/diff/use-diff-viewer-state.ts b/apps/web/components/diff/use-diff-viewer-state.ts
index 7a999efa2e..ee694f3b57 100644
--- a/apps/web/components/diff/use-diff-viewer-state.ts
+++ b/apps/web/components/diff/use-diff-viewer-state.ts
@@ -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;
/** 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>;
setShowCommentForm: React.Dispatch>;
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) => 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,
};
}
diff --git a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx
index 847bdf8edc..d19a529dd7 100644
--- a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx
+++ b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx
@@ -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}
/>
)}
diff --git a/apps/web/components/editors/codemirror/use-codemirror-editor-state.ts b/apps/web/components/editors/codemirror/use-codemirror-editor-state.ts
index a8ee2ab22a..f5fd66a286 100644
--- a/apps/web/components/editors/codemirror/use-codemirror-editor-state.ts
+++ b/apps/web/components/editors/codemirror/use-codemirror-editor-state.ts
@@ -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,
diff --git a/apps/web/components/editors/monaco/monaco-code-editor.tsx b/apps/web/components/editors/monaco/monaco-code-editor.tsx
index 93f28173a5..120612cdb3 100644
--- a/apps/web/components/editors/monaco/monaco-code-editor.tsx
+++ b/apps/web/components/editors/monaco/monaco-code-editor.tsx
@@ -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(
state.handleCommentSubmitRef.current(c)}
+ onSubmitAndRun={(c) => state.handleCommentSubmitAndRunRef.current(c)}
onCancel={() => {
state.setFormZoneRange(null);
state.clearGutterSelection();
diff --git a/apps/web/components/editors/monaco/monaco-diff-viewer.tsx b/apps/web/components/editors/monaco/monaco-diff-viewer.tsx
index 7f9df3c298..083d33274e 100644
--- a/apps/web/components/editors/monaco/monaco-diff-viewer.tsx
+++ b/apps/web/components/editors/monaco/monaco-diff-viewer.tsx
@@ -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(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,
+ }),
};
}
diff --git a/apps/web/components/editors/monaco/use-diff-view-zones.ts b/apps/web/components/editors/monaco/use-diff-view-zones.ts
index 61dce4875f..3a7e9a2aa8 100644
--- a/apps/web/components/editors/monaco/use-diff-view-zones.ts
+++ b/apps/web/components/editors/monaco/use-diff-view-zones.ts
@@ -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,
diff --git a/apps/web/components/editors/monaco/use-diff-viewer-comments.ts b/apps/web/components/editors/monaco/use-diff-viewer-comments.ts
index a50df5d76d..f0500147be 100644
--- a/apps/web/components/editors/monaco/use-diff-viewer-comments.ts
+++ b/apps/web/components/editors/monaco/use-diff-viewer-comments.ts
@@ -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,
diff --git a/apps/web/components/editors/monaco/use-monaco-editor-state.ts b/apps/web/components/editors/monaco/use-monaco-editor-state.ts
index 7b785e6f25..4fdeb3c2f8 100644
--- a/apps/web/components/editors/monaco/use-monaco-editor-state.ts
+++ b/apps/web/components/editors/monaco/use-monaco-editor-state.ts
@@ -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,
};
}
diff --git a/apps/web/components/review/review-diff-list.tsx b/apps/web/components/review/review-diff-list.tsx
index f651aad53c..1e55a4ea2a 100644
--- a/apps/web/components/review/review-diff-list.tsx
+++ b/apps/web/components/review/review-diff-list.tsx
@@ -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 (
@@ -351,6 +371,7 @@ function FileDiffSection({
enableComments
enableAcceptReject
onRevertBlock={handleRevertBlock}
+ onCommentRun={handleCommentRun}
sessionId={sessionId}
wordWrap={wordWrap}
enableExpansion={true}
diff --git a/apps/web/components/task/editor-comment-popover.tsx b/apps/web/components/task/editor-comment-popover.tsx
index bae45a0916..a829f88fd7 100644
--- a/apps/web/components/task/editor-comment-popover.tsx
+++ b/apps/web/components/task/editor-comment-popover.tsx
@@ -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
;
}) {
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"
/>
-
{"\u2318"}+Enter to add
-
+
+ {modKey}+Enter to add{handleSubmitAndRun ? `, ${modKey}+Shift+Enter to run` : ""}
+
+
+
+
+
+
+
+
+ Save comment for review ({modKey}+Enter)
+
+
+ {handleSubmitAndRun && (
+
+
+
+
+
+ Save and send to agent ({modKey}+Shift+Enter)
+
+
+ )}
+
+
);
@@ -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}
/>
diff --git a/apps/web/components/task/plan-selection-popover.tsx b/apps/web/components/task/plan-selection-popover.tsx
index c34c37fb27..a87b6642b7 100644
--- a/apps/web/components/task/plan-selection-popover.tsx
+++ b/apps/web/components/task/plan-selection-popover.tsx
@@ -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 (
+
+
+
+ ⌘+Enter to {isEditing ? "update" : "add"}
+ {onSubmitAndRun && !isEditing ? ", ⌘+Shift+Enter to run" : ""}
+
+ {isEditing && onDelete && (
+
+ )}
+
+
+
+
+
+
+
+
+ {isEditing ? "Update comment" : "Save comment for review"}
+
+
+ {onSubmitAndRun && !isEditing && (
+
+
+
+
+
+ Save and send to agent
+
+
+ )}
+
+
+
+ );
+}
+
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(
- {/* Drag handle */}
-
- {/* Text preview */}
“{previewText}”
-
- {/* Comment input */}
-
- {/* Actions */}
-
-
-
- ⌘+Enter to {editingComment ? "update" : "add"}
-
- {editingComment && onDelete && (
-
- )}
-
-
-
+
,
document.body,
diff --git a/apps/web/components/task/task-plan-panel.tsx b/apps/web/components/task/task-plan-panel.tsx
index d06e5a9677..eb9706d7bd 100644
--- a/apps/web/components/task/task-plan-panel.tsx
+++ b/apps/web/components/task/task-plan-panel.tsx
@@ -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({
;
editorRef: React.RefObject;
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}
diff --git a/apps/web/hooks/domains/comments/use-run-comment.ts b/apps/web/hooks/domains/comments/use-run-comment.ts
new file mode 100644
index 0000000000..26c84cafe7
--- /dev/null
+++ b/apps/web/hooks/domains/comments/use-run-comment.ts
@@ -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 };
+}
diff --git a/apps/web/lib/api/domains/queue-api.ts b/apps/web/lib/api/domains/queue-api.ts
index fa983fe75f..c0415d3098 100644
--- a/apps/web/lib/api/domains/queue-api.ts
+++ b/apps/web/lib/api/domains/queue-api.ts
@@ -43,6 +43,22 @@ export async function getQueueStatus(sessionId: string): Promise {
return client.request("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 {
+ const client = getWebSocketClient();
+ if (!client) {
+ throw new Error(WS_CLIENT_UNAVAILABLE);
+ }
+
+ return client.request("message.queue.append", params);
+}
+
// Update queued message content (for arrow up editing)
export async function updateQueuedMessage(
sessionId: string,