From 7f956d2f82cdea1a83bb869e1ce6b8cf6efbfc1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Mon, 23 Mar 2026 23:51:14 +0000 Subject: [PATCH] feat(web): add image and file paste/drop support to task creation dialog (#453) --- apps/backend/cmd/kandev/adapters.go | 22 +- .../agent/lifecycle/manager_launch.go | 18 +- .../agent/lifecycle/manager_profile.go | 4 +- .../internal/agent/lifecycle/session.go | 27 +- .../internal/agent/lifecycle/session_test.go | 8 +- .../backend/internal/agent/lifecycle/types.go | 17 +- .../orchestrator/event_handlers_github.go | 1 + .../orchestrator/event_handlers_workflow.go | 2 +- .../orchestrator/executor/executor.go | 4 +- .../orchestrator/executor/executor_execute.go | 5 + .../internal/orchestrator/session_launch.go | 29 +- .../internal/orchestrator/task_operations.go | 94 +++--- .../orchestrator/task_operations_test.go | 12 +- .../task/handlers/message_handlers.go | 4 +- .../task/handlers/task_http_handlers.go | 45 +++ .../task/handlers/task_ws_handlers.go | 45 +-- .../components/task-create-dialog-helpers.ts | 56 ++++ .../task-create-dialog-selectors.tsx | 304 +++++++++++++++--- .../components/task-create-dialog-state.ts | 3 +- .../components/task-create-dialog-submit.tsx | 140 +++----- .../components/task-create-dialog-types.ts | 58 +++- apps/web/lib/api/domains/kanban-api.ts | 2 + .../lib/services/session-launch-helpers.ts | 4 +- .../lib/services/session-launch-service.ts | 8 + 24 files changed, 673 insertions(+), 239 deletions(-) diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index 0fce379da6..d60022937b 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -75,6 +75,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch AgentProfileID: req.AgentProfileID, WorkspacePath: req.RepositoryURL, // May be empty - lifecycle manager handles this TaskDescription: req.TaskDescription, + Attachments: convertToLifecycleAttachments(req.Attachments), Env: req.Env, ACPSessionID: req.ACPSessionID, Metadata: req.Metadata, @@ -125,6 +126,23 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch }, nil } +// convertToLifecycleAttachments converts v1.MessageAttachment to lifecycle.MessageAttachment. +func convertToLifecycleAttachments(attachments []v1.MessageAttachment) []lifecycle.MessageAttachment { + if len(attachments) == 0 { + return nil + } + result := make([]lifecycle.MessageAttachment, len(attachments)) + for i, att := range attachments { + result[i] = lifecycle.MessageAttachment{ + Type: att.Type, + Data: att.Data, + MimeType: att.MimeType, + Name: att.Name, + } + } + return result +} + // SetExecutionDescription updates the task description in an existing execution's metadata. func (a *lifecycleAdapter) SetExecutionDescription(ctx context.Context, agentExecutionID string, description string) error { return a.mgr.SetExecutionDescription(ctx, agentExecutionID, description) @@ -377,8 +395,8 @@ func (w *orchestratorWrapper) ResumeTaskSession(ctx context.Context, taskID, tas } // StartCreatedSession forwards to the orchestrator service, discarding the TaskExecution result. -func (w *orchestratorWrapper) StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode bool) error { - _, err := w.svc.StartCreatedSession(ctx, taskID, sessionID, agentProfileID, prompt, skipMessageRecord, planMode) +func (w *orchestratorWrapper) StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode bool, attachments []v1.MessageAttachment) error { + _, err := w.svc.StartCreatedSession(ctx, taskID, sessionID, agentProfileID, prompt, skipMessageRecord, planMode, attachments) return err } diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go index 163d5bdb40..971532e74f 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch.go @@ -176,6 +176,9 @@ func (m *Manager) launchPrepareRequest(req *LaunchRequest, profileInfo *AgentPro if req.TaskDescription != "" { reqWithWorktree.Metadata["task_description"] = req.TaskDescription } + if len(req.Attachments) > 0 { + reqWithWorktree.Metadata["attachments"] = req.Attachments + } if req.SessionID != "" { reqWithWorktree.Metadata["session_id"] = req.SessionID } @@ -576,6 +579,18 @@ func getTaskDescriptionFromMetadata(execution *AgentExecution) string { return "" } +// getAttachmentsFromMetadata extracts attachments from execution metadata. +func getAttachmentsFromMetadata(execution *AgentExecution) []MessageAttachment { + if execution.Metadata == nil { + return nil + } + attachments, ok := execution.Metadata["attachments"].([]MessageAttachment) + if ok { + return attachments + } + return nil +} + // configureAndStartAgent configures the agent command and starts the agent subprocess. // Returns the effective boot command (full command with adapter args, or base command). func (m *Manager) configureAndStartAgent(ctx context.Context, execution *AgentExecution, taskDescription, approvalPolicy string) (string, error) { @@ -622,7 +637,8 @@ func (m *Manager) initializeAgentSession(ctx context.Context, execution *AgentEx return fmt.Errorf("failed to resolve MCP config: %w", err) } - if err := m.initializeACPSession(ctx, execution, agentConfig, taskDescription, mcpServers); err != nil { + attachments := getAttachmentsFromMetadata(execution) + if err := m.initializeACPSession(ctx, execution, agentConfig, taskDescription, attachments, mcpServers); err != nil { m.finalizeBootMessage(execution, bootMsg, bootStopCh, execution.agentctl, "failed") m.updateExecutionError(execution.ID, "failed to initialize ACP: "+err.Error()) return fmt.Errorf("failed to initialize ACP: %w", err) diff --git a/apps/backend/internal/agent/lifecycle/manager_profile.go b/apps/backend/internal/agent/lifecycle/manager_profile.go index cd8f7c8fab..3192feb0f9 100644 --- a/apps/backend/internal/agent/lifecycle/manager_profile.go +++ b/apps/backend/internal/agent/lifecycle/manager_profile.go @@ -158,7 +158,7 @@ func (m *Manager) resolveProfileModel(ctx context.Context, profileID string) str } // initializeACPSession delegates to SessionManager for full ACP session initialization and prompting -func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent, taskDescription string, mcpServers []agentctltypes.McpServer) error { +func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent, taskDescription string, attachments []MessageAttachment, mcpServers []agentctltypes.McpServer) error { profileModel := m.resolveProfileModel(ctx, execution.AgentProfileID) - return m.sessionManager.InitializeAndPrompt(ctx, execution, agentConfig, taskDescription, mcpServers, m.MarkReady, profileModel) + return m.sessionManager.InitializeAndPrompt(ctx, execution, agentConfig, taskDescription, attachments, mcpServers, m.MarkReady, profileModel) } diff --git a/apps/backend/internal/agent/lifecycle/session.go b/apps/backend/internal/agent/lifecycle/session.go index 9c662088ff..9228f04dbd 100644 --- a/apps/backend/internal/agent/lifecycle/session.go +++ b/apps/backend/internal/agent/lifecycle/session.go @@ -262,6 +262,7 @@ func (sm *SessionManager) InitializeAndPrompt( execution *AgentExecution, agentConfig agents.Agent, taskDescription string, + attachments []MessageAttachment, mcpServers []agentctltypes.McpServer, markReady func(executionID string) error, profileModel string, @@ -356,18 +357,35 @@ func (sm *SessionManager) InitializeAndPrompt( } // Send the task prompt if provided, or mark the execution as ready. - sm.dispatchInitialPrompt(ctx, execution, agentConfig, taskDescription, markReady) + sm.dispatchInitialPrompt(ctx, execution, agentConfig, taskDescription, attachments, markReady) return nil } +// convertAttachments converts lifecycle.MessageAttachment to v1.MessageAttachment for ACP. +func convertAttachments(attachments []MessageAttachment) []v1.MessageAttachment { + if len(attachments) == 0 { + return nil + } + result := make([]v1.MessageAttachment, 0, len(attachments)) + for _, att := range attachments { + result = append(result, v1.MessageAttachment{ + Type: att.Type, + Data: att.Data, + MimeType: att.MimeType, + Name: att.Name, + }) + } + return result +} + // dispatchInitialPrompt sends the initial task prompt or marks the execution as ready. // For sessions with a task description, sends the prompt asynchronously. // For resumed sessions (fork_session pattern), defers context injection to the first user message. // For all other cases, marks the execution ready immediately. -func (sm *SessionManager) dispatchInitialPrompt(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent, taskDescription string, markReady func(executionID string) error) { +func (sm *SessionManager) dispatchInitialPrompt(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent, taskDescription string, attachments []MessageAttachment, markReady func(executionID string) error) { switch { - case taskDescription != "": + case taskDescription != "" || len(attachments) > 0: promptWithContext := sm.injectKandevContext(execution.TaskID, execution.SessionID, taskDescription) effectivePrompt := sm.getResumeContextPrompt(agentConfig, execution.SessionID, promptWithContext) if effectivePrompt != taskDescription { @@ -377,10 +395,11 @@ func (sm *SessionManager) dispatchInitialPrompt(ctx context.Context, execution * zap.Int("original_length", len(taskDescription)), zap.Int("effective_length", len(effectivePrompt))) } + acpAttachments := convertAttachments(attachments) go func() { promptCtx, cancel := appctx.Detached(ctx, sm.stopCh, constants.PromptTimeout) defer cancel() - _, err := sm.SendPrompt(promptCtx, execution, effectivePrompt, false, nil) + _, err := sm.SendPrompt(promptCtx, execution, effectivePrompt, false, acpAttachments) if err != nil { sm.logger.Error("initial prompt failed", zap.String("execution_id", execution.ID), diff --git a/apps/backend/internal/agent/lifecycle/session_test.go b/apps/backend/internal/agent/lifecycle/session_test.go index 2e09070e89..34c3021487 100644 --- a/apps/backend/internal/agent/lifecycle/session_test.go +++ b/apps/backend/internal/agent/lifecycle/session_test.go @@ -232,7 +232,7 @@ func TestInitializeAndPrompt_StreamBeforeInitialize(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, func(executionID string) error { + err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, nil, func(executionID string) error { return nil }, "") if err != nil { @@ -304,7 +304,7 @@ func TestInitializeAndPrompt_StreamTimeout(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, func(executionID string) error { + err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, nil, func(executionID string) error { return nil }, "") @@ -357,7 +357,7 @@ func TestInitializeAndPrompt_WithTaskDescription(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "Build a feature", nil, func(executionID string) error { + err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "Build a feature", nil, nil, func(executionID string) error { return nil }, "") if err != nil { @@ -425,7 +425,7 @@ func TestInitializeAndPrompt_NoStreamManager(t *testing.T) { // This will fail because the stream isn't connected (sendStreamRequest returns error). // But it should NOT panic due to nil streamManager. - err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, func(executionID string) error { + err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, nil, func(executionID string) error { return nil }, "") diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index ae9e121fd4..2b24fe4c0b 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -248,10 +248,11 @@ type LaunchRequest struct { SessionID string TaskTitle string // Human-readable task title for semantic worktree naming AgentProfileID string - WorkspacePath string // Host path to workspace (original repository path) - TaskDescription string // Task description to send via ACP prompt - Env map[string]string // Additional env vars - ACPSessionID string // ACP session ID to resume, if available + WorkspacePath string // Host path to workspace (original repository path) + TaskDescription string // Task description to send via ACP prompt + Attachments []MessageAttachment // Attachments (images/files) for the initial prompt + Env map[string]string // Additional env vars + ACPSessionID string // ACP session ID to resume, if available Metadata map[string]interface{} ModelOverride string // If set, use this model instead of the profile's model @@ -274,6 +275,14 @@ type LaunchRequest struct { PullBeforeWorktree bool // Whether to pull from remote before creating the worktree } +// MessageAttachment represents an image or file attachment for agent prompts. +type MessageAttachment struct { + Type string // "image", "audio", or "resource" + Data string // base64-encoded data + MimeType string // MIME type + Name string // optional filename for resource attachments +} + // CredentialsManager interface for credential retrieval type CredentialsManager interface { GetCredentialValue(ctx context.Context, key string) (value string, err error) diff --git a/apps/backend/internal/orchestrator/event_handlers_github.go b/apps/backend/internal/orchestrator/event_handlers_github.go index f6cdf97b0a..8d43c16f18 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github.go +++ b/apps/backend/internal/orchestrator/event_handlers_github.go @@ -214,6 +214,7 @@ func (s *Service) autoStartReviewTask( task.Description, evt.WorkflowStepID, false, + nil, ) if err != nil { s.logger.Error("failed to auto-start review task", diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index f6e4df31ef..e3e50359ee 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -334,7 +334,7 @@ func (s *Service) handleTaskMovedNoSession(ctx context.Context, data watcher.Tas zap.String("executor_profile_id", executorProfileID), zap.Bool("plan_mode", planMode)) - _, err = s.StartTask(ctx, task.ID, agentProfileID, "", executorProfileID, 0, task.Description, data.ToStepID, planMode) + _, err = s.StartTask(ctx, task.ID, agentProfileID, "", executorProfileID, 0, task.Description, data.ToStepID, planMode, nil) if err != nil { s.logger.Error("task.moved: failed to auto-start task", zap.String("task_id", data.TaskID), diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index fbc6e9f159..03b490d005 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -210,7 +210,8 @@ type LaunchAgentRequest struct { AgentProfileID string RepositoryURL string Branch string - TaskDescription string // Task description to send via ACP prompt + TaskDescription string // Task description to send via ACP prompt + Attachments []v1.MessageAttachment // Attachments for the initial prompt (images/files) Priority int Metadata map[string]interface{} Env map[string]string @@ -246,6 +247,7 @@ type LaunchOptions struct { WorkflowStepID string StartAgent bool McpMode string // MCP tool mode: McpModeConfig activates config-mode tools + Attachments []v1.MessageAttachment } // LaunchAgentResponse contains the result of launching an agent diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 5f9bba944b..6d245b7bde 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -340,6 +340,11 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses req.McpMode = opts.McpMode } + // Pass attachments for the initial prompt + if len(opts.Attachments) > 0 { + req.Attachments = opts.Attachments + } + e.logger.Info("launching agent for prepared session", zap.String("task_id", task.ID), zap.String("session_id", sessionID), diff --git a/apps/backend/internal/orchestrator/session_launch.go b/apps/backend/internal/orchestrator/session_launch.go index e0874efe95..98a87204fa 100644 --- a/apps/backend/internal/orchestrator/session_launch.go +++ b/apps/backend/internal/orchestrator/session_launch.go @@ -26,18 +26,19 @@ const ( // LaunchSessionRequest is the unified request for session.launch. type LaunchSessionRequest struct { - TaskID string `json:"task_id"` - Intent SessionIntent `json:"intent,omitempty"` - SessionID string `json:"session_id,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - ExecutorID string `json:"executor_id,omitempty"` - ExecutorProfileID string `json:"executor_profile_id,omitempty"` - Prompt string `json:"prompt,omitempty"` - PlanMode bool `json:"plan_mode,omitempty"` - WorkflowStepID string `json:"workflow_step_id,omitempty"` - Priority int `json:"priority,omitempty"` - LaunchWorkspace bool `json:"launch_workspace,omitempty"` - SkipMessageRecord bool `json:"skip_message_record,omitempty"` + TaskID string `json:"task_id"` + Intent SessionIntent `json:"intent,omitempty"` + SessionID string `json:"session_id,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + ExecutorID string `json:"executor_id,omitempty"` + ExecutorProfileID string `json:"executor_profile_id,omitempty"` + Prompt string `json:"prompt,omitempty"` + PlanMode bool `json:"plan_mode,omitempty"` + WorkflowStepID string `json:"workflow_step_id,omitempty"` + Priority int `json:"priority,omitempty"` + LaunchWorkspace bool `json:"launch_workspace,omitempty"` + SkipMessageRecord bool `json:"skip_message_record,omitempty"` + Attachments []v1.MessageAttachment `json:"attachments,omitempty"` } // LaunchSessionResponse is the unified response for session.launch. @@ -121,7 +122,7 @@ func (s *Service) launchStart(ctx context.Context, req *LaunchSessionRequest) (* execution, err := s.StartTask( ctx, req.TaskID, req.AgentProfileID, req.ExecutorID, req.ExecutorProfileID, req.Priority, req.Prompt, - req.WorkflowStepID, req.PlanMode, + req.WorkflowStepID, req.PlanMode, req.Attachments, ) if err != nil { return nil, err @@ -133,7 +134,7 @@ func (s *Service) launchStart(ctx context.Context, req *LaunchSessionRequest) (* func (s *Service) launchStartCreated(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) { execution, err := s.StartCreatedSession( ctx, req.TaskID, req.SessionID, req.AgentProfileID, - req.Prompt, req.SkipMessageRecord, req.PlanMode, + req.Prompt, req.SkipMessageRecord, req.PlanMode, req.Attachments, ) if err != nil { return nil, err diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 4758a4b911..81f9e22daf 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -202,7 +202,7 @@ func (s *Service) StartTaskWithSession(ctx context.Context, taskID string, sessi } if execution.SessionID != "" { - s.recordInitialMessage(ctx, taskID, execution.SessionID, effectivePrompt, planModeActive) + s.recordInitialMessage(ctx, taskID, execution.SessionID, effectivePrompt, planModeActive, nil) if planModeActive { sess, sessErr := s.repo.GetTaskSession(ctx, execution.SessionID) @@ -223,7 +223,7 @@ func (s *Service) StartTaskWithSession(ctx context.Context, taskID string, sessi // and the user now wants to start the agent with a prompt (e.g., from the plan panel or chat). // When skipMessageRecord is true, only the session state is updated (the caller already stored the user message). // When planMode is true, plan mode instructions are injected into the prompt and session metadata is set. -func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode bool) (*executor.TaskExecution, error) { +func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode bool, attachments []v1.MessageAttachment) (*executor.TaskExecution, error) { s.logger.Debug("starting created session", zap.String("task_id", taskID), zap.String("session_id", sessionID), @@ -297,7 +297,7 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag executorID := session.ExecutorID - execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, executor.LaunchOptions{AgentProfileID: effectiveProfileID, ExecutorID: executorID, Prompt: effectivePrompt, StartAgent: true}) + execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, executor.LaunchOptions{AgentProfileID: effectiveProfileID, ExecutorID: executorID, Prompt: effectivePrompt, StartAgent: true, Attachments: attachments}) if err != nil { return nil, err } @@ -305,7 +305,7 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag // Record the initial user message and set plan mode metadata after launch. // Note: we do NOT set session state here — the executor sets it to STARTING, // and event handlers (handleAgentReady) transition it to WAITING_FOR_INPUT. - s.postLaunchCreated(ctx, taskID, sessionID, effectivePrompt, skipMessageRecord, planModeActive) + s.postLaunchCreated(ctx, taskID, sessionID, effectivePrompt, skipMessageRecord, planModeActive, attachments) return execution, nil } @@ -314,9 +314,9 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag // records the initial user message (unless skipped) and sets plan mode metadata. // It does NOT modify session state — the executor sets STARTING, and event handlers // (handleAgentReady) handle the transition to WAITING_FOR_INPUT. -func (s *Service) postLaunchCreated(ctx context.Context, taskID, sessionID, prompt string, skipMessage, planModeActive bool) { +func (s *Service) postLaunchCreated(ctx context.Context, taskID, sessionID, prompt string, skipMessage, planModeActive bool, attachments []v1.MessageAttachment) { if !skipMessage { - s.recordInitialMessage(ctx, taskID, sessionID, prompt, planModeActive) + s.recordInitialMessage(ctx, taskID, sessionID, prompt, planModeActive, attachments) } if planModeActive { @@ -333,7 +333,7 @@ func (s *Service) postLaunchCreated(ctx context.Context, taskID, sessionID, prom // applied if the step has plan_mode enabled. // If planMode is true and the workflow step doesn't already apply plan mode, // default plan mode instructions are injected into the prompt. -func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, priority int, prompt string, workflowStepID string, planMode bool) (*executor.TaskExecution, error) { +func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, priority int, prompt string, workflowStepID string, planMode bool, attachments []v1.MessageAttachment) (*executor.TaskExecution, error) { s.logger.Debug("manually starting task", zap.String("task_id", taskID), zap.String("agent_profile_id", agentProfileID), @@ -341,7 +341,8 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s zap.Int("priority", priority), zap.Int("prompt_length", len(prompt)), zap.String("workflow_step_id", workflowStepID), - zap.Bool("plan_mode", planMode)) + zap.Bool("plan_mode", planMode), + zap.Int("attachments", len(attachments))) if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateScheduling); err != nil { s.logger.Warn("failed to update task state to SCHEDULING", @@ -349,26 +350,7 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s zap.Error(err)) } - // Move task to the target workflow step if provided and different from current - if workflowStepID != "" { - dbTask, err := s.repo.GetTask(ctx, taskID) - if err == nil && dbTask.WorkflowStepID != workflowStepID { - dbTask.WorkflowStepID = workflowStepID - dbTask.UpdatedAt = time.Now().UTC() - if err := s.repo.UpdateTask(ctx, dbTask); err != nil { - s.logger.Warn("failed to move task to workflow step", - zap.String("task_id", taskID), - zap.String("workflow_step_id", workflowStepID), - zap.Error(err)) - } else if s.eventBus != nil { - _ = s.eventBus.Publish(ctx, events.TaskUpdated, bus.NewEvent( - events.TaskUpdated, - "orchestrator", - buildTaskEventPayload(dbTask), - )) - } - } - } + s.moveTaskToWorkflowStep(ctx, taskID, workflowStepID) // Fetch the task from the repository to get complete task info task, err := s.scheduler.GetTask(ctx, taskID) @@ -411,18 +393,51 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s Prompt: effectivePrompt, WorkflowStepID: workflowStepID, StartAgent: true, + Attachments: attachments, }) if err != nil { return nil, err } - if execution.SessionID != "" { - s.recordInitialMessage(ctx, taskID, execution.SessionID, effectivePrompt, planModeActive || configMode) + s.postLaunchStart(ctx, taskID, execution, effectivePrompt, planModeActive || configMode, planModeActive, attachments) - // Set plan mode in session metadata so the frontend can detect it. - // applyWorkflowAndPlanMode only injects plan mode into the prompt text; - // the session metadata is needed for the frontend to switch the layout. - if planModeActive { + // Note: Task stays in SCHEDULING state until the agent is fully initialized. + // The executor will transition to IN_PROGRESS after StartAgentProcess() succeeds. + + return execution, nil +} + +// moveTaskToWorkflowStep moves a task to the target workflow step if provided and different from current. +func (s *Service) moveTaskToWorkflowStep(ctx context.Context, taskID, workflowStepID string) { + if workflowStepID == "" { + return + } + dbTask, err := s.repo.GetTask(ctx, taskID) + if err != nil || dbTask.WorkflowStepID == workflowStepID { + return + } + dbTask.WorkflowStepID = workflowStepID + dbTask.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateTask(ctx, dbTask); err != nil { + s.logger.Warn("failed to move task to workflow step", + zap.String("task_id", taskID), + zap.String("workflow_step_id", workflowStepID), + zap.Error(err)) + } else if s.eventBus != nil { + _ = s.eventBus.Publish(ctx, events.TaskUpdated, bus.NewEvent( + events.TaskUpdated, + "orchestrator", + buildTaskEventPayload(dbTask), + )) + } +} + +// postLaunchStart records the initial message and sets plan mode after a successful launch. +func (s *Service) postLaunchStart(ctx context.Context, taskID string, execution *executor.TaskExecution, prompt string, recordPlanMode, setPlanMode bool, attachments []v1.MessageAttachment) { + if execution.SessionID != "" { + s.recordInitialMessage(ctx, taskID, execution.SessionID, prompt, recordPlanMode, attachments) + + if setPlanMode { session, err := s.repo.GetTaskSession(ctx, execution.SessionID) if err == nil { s.setSessionPlanMode(ctx, session, true) @@ -432,11 +447,6 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s if execution.WorktreeBranch != "" { go s.ensureSessionPRWatch(context.Background(), taskID, execution.SessionID, execution.WorktreeBranch) } - - // Note: Task stays in SCHEDULING state until the agent is fully initialized. - // The executor will transition to IN_PROGRESS after StartAgentProcess() succeeds. - - return execution, nil } // applyWorkflowAndPlanMode applies workflow step configuration and plan mode injection to a prompt. @@ -470,9 +480,9 @@ func (s *Service) applyWorkflowAndPlanMode(ctx context.Context, prompt string, t } // recordInitialMessage creates the initial user message and updates session state after launch. -func (s *Service) recordInitialMessage(ctx context.Context, taskID, sessionID, prompt string, planModeActive bool) { - if s.messageCreator != nil && prompt != "" { - meta := NewUserMessageMeta().WithPlanMode(planModeActive) +func (s *Service) recordInitialMessage(ctx context.Context, taskID, sessionID, prompt string, planModeActive bool, attachments []v1.MessageAttachment) { + if s.messageCreator != nil && (prompt != "" || len(attachments) > 0) { + meta := NewUserMessageMeta().WithPlanMode(planModeActive).WithAttachments(attachments) if err := s.messageCreator.CreateUserMessage(ctx, taskID, prompt, sessionID, s.getActiveTurnID(sessionID), meta.ToMap()); err != nil { s.logger.Error("failed to create initial user message", zap.String("task_id", taskID), diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index 3b3fc590b9..c5e8b02d6c 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -143,7 +143,7 @@ func TestStartCreatedSession_WrongTask(t *testing.T) { // Session belongs to "task-other", not "task1" seedTaskAndSession(t, repo, "task-other", "session1", models.TaskSessionStateCreated) - _, err := svc.StartCreatedSession(context.Background(), "task1", "session1", "profile1", "prompt", false, false) + _, err := svc.StartCreatedSession(context.Background(), "task1", "session1", "profile1", "prompt", false, false, nil) if err == nil { t.Fatal("expected error when session does not belong to task") } @@ -155,7 +155,7 @@ func TestStartCreatedSession_NotInCreatedState(t *testing.T) { seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateRunning) - _, err := svc.StartCreatedSession(context.Background(), "task1", "session1", "profile1", "prompt", false, false) + _, err := svc.StartCreatedSession(context.Background(), "task1", "session1", "profile1", "prompt", false, false, nil) if err == nil { t.Fatal("expected error when session is not in CREATED state") } @@ -229,7 +229,7 @@ func TestRecordInitialMessage_DoesNotChangeSessionState(t *testing.T) { svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) svc.messageCreator = mc - svc.recordInitialMessage(ctx, "task1", "session1", "hello world", false) + svc.recordInitialMessage(ctx, "task1", "session1", "hello world", false, nil) // Session state must remain STARTING — recordInitialMessage should not modify state. session, err := repo.GetTaskSession(ctx, "session1") @@ -256,7 +256,7 @@ func TestPostLaunchCreated_SkipMessage_DoesNotChangeSessionState(t *testing.T) { svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - svc.postLaunchCreated(ctx, "task1", "session1", "prompt", true, false) + svc.postLaunchCreated(ctx, "task1", "session1", "prompt", true, false, nil) // Session state must remain STARTING when skipMessage=true. session, err := repo.GetTaskSession(ctx, "session1") @@ -277,7 +277,7 @@ func TestPostLaunchCreated_WithMessage_DoesNotChangeSessionState(t *testing.T) { svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) svc.messageCreator = mc - svc.postLaunchCreated(ctx, "task1", "session1", "hello", false, false) + svc.postLaunchCreated(ctx, "task1", "session1", "hello", false, false, nil) // Session state must remain STARTING — postLaunchCreated delegates to // recordInitialMessage which only creates the message. @@ -303,7 +303,7 @@ func TestPostLaunchCreated_PlanMode_SetsMetadata(t *testing.T) { svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) svc.messageCreator = mc - svc.postLaunchCreated(ctx, "task1", "session1", "plan this", false, true) + svc.postLaunchCreated(ctx, "task1", "session1", "plan this", false, true, nil) // User message should have plan_mode metadata. if len(mc.userMessages) != 1 { diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index 77e9e282ad..f60f3e7450 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -26,7 +26,7 @@ import ( type OrchestratorService interface { PromptTask(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment) (*orchestrator.PromptResult, error) ResumeTaskSession(ctx context.Context, taskID, taskSessionID string) error - StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode bool) error + StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode bool, attachments []v1.MessageAttachment) error ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error } @@ -344,7 +344,7 @@ func (h *MessageHandlers) forwardMessageAsPrompt( ) { // For CREATED sessions, start the agent with this message as the initial prompt if startCreated { - if err := h.orchestrator.StartCreatedSession(ctx, taskID, sessionID, agentProfileID, content, true, planMode); err != nil { + if err := h.orchestrator.StartCreatedSession(ctx, taskID, sessionID, agentProfileID, content, true, planMode, attachments); err != nil { h.logger.Warn("failed to start created session from message", zap.String("task_id", taskID), zap.String("session_id", sessionID), diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index d02d85a39c..c01d3b540d 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "fmt" "net/http" "strconv" "strings" @@ -327,6 +328,7 @@ type httpCreateTaskRequest struct { ExecutorID string `json:"executor_id,omitempty"` ExecutorProfileID string `json:"executor_profile_id,omitempty"` PlanMode bool `json:"plan_mode,omitempty"` + Attachments []v1.MessageAttachment `json:"attachments,omitempty"` } type createTaskResponse struct { @@ -335,12 +337,54 @@ type createTaskResponse struct { AgentExecutionID string `json:"agent_execution_id,omitempty"` } +const ( + maxCreateTaskAttachments = 10 + maxAttachmentDataBytes = 10 * 1024 * 1024 // 10 MB base64 string length cap +) + +var allowedAttachmentTypes = map[string]struct{}{ + "image": {}, + "audio": {}, + "resource": {}, +} + +func validateAttachments(items []v1.MessageAttachment) error { + if len(items) > maxCreateTaskAttachments { + return fmt.Errorf("too many attachments (max %d)", maxCreateTaskAttachments) + } + var totalSize int + for i, a := range items { + typ := strings.TrimSpace(a.Type) + if _, ok := allowedAttachmentTypes[typ]; !ok { + return fmt.Errorf("attachment[%d] has unsupported type %q", i, typ) + } + if strings.TrimSpace(a.MimeType) == "" { + return fmt.Errorf("attachment[%d] mime_type is required", i) + } + if len(a.Data) == 0 { + return fmt.Errorf("attachment[%d] data is required", i) + } + if len(a.Data) > maxAttachmentDataBytes { + return fmt.Errorf("attachment[%d] data exceeds size limit", i) + } + totalSize += len(a.Data) + } + if totalSize > maxAttachmentDataBytes { + return fmt.Errorf("total attachment size exceeds limit") + } + return nil +} + func (h *TaskHandlers) httpCreateTask(c *gin.Context) { var body httpCreateTaskRequest if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } + if err := validateAttachments(body.Attachments); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } if body.WorkspaceID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"}) return @@ -493,6 +537,7 @@ func (h *TaskHandlers) startAgentForNewTask( Prompt: description, SkipMessageRecord: false, PlanMode: body.PlanMode, + Attachments: body.Attachments, }) if err != nil { h.logger.Error("failed to start agent for task (async)", zap.Error(err), zap.String("task_id", taskID), zap.String("session_id", sessionID)) diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index 35b3a6f770..3494c5b38a 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -90,6 +90,7 @@ type wsCreateTaskRequest struct { ExecutorID string `json:"executor_id,omitempty"` ExecutorProfileID string `json:"executor_profile_id,omitempty"` PlanMode bool `json:"plan_mode,omitempty"` + Attachments []v1.MessageAttachment `json:"attachments,omitempty"` } func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -163,34 +164,42 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M taskDTO := dto.FromTask(task) response := createTaskResponse{TaskDTO: taskDTO} if req.StartAgent && req.AgentProfileID != "" && h.orchestrator != nil { - // Use task description as the initial prompt with workflow step config - // Use taskDTO.WorkflowStepID (backend-resolved) instead of req.WorkflowStepID - launchResp, err := h.orchestrator.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{ - TaskID: taskDTO.ID, - Intent: orchestrator.IntentStart, - AgentProfileID: req.AgentProfileID, - ExecutorID: req.ExecutorID, - ExecutorProfileID: req.ExecutorProfileID, - Priority: req.Priority, - Prompt: taskDTO.Description, - WorkflowStepID: taskDTO.WorkflowStepID, - PlanMode: req.PlanMode, - }) + launchResp, err := h.launchAgentForNewTask(ctx, taskDTO, req) if err != nil { h.logger.Error("failed to start agent for task", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to start agent for task", nil) } - h.logger.Info("wsCreateTask started agent", - zap.String("task_id", taskDTO.ID), - zap.String("executor_id", req.ExecutorID), - zap.String("workflow_step_id", taskDTO.WorkflowStepID), - zap.String("session_id", launchResp.SessionID)) response.TaskSessionID = launchResp.SessionID response.AgentExecutionID = launchResp.AgentExecutionID } return ws.NewResponse(msg.ID, msg.Action, response) } +// launchAgentForNewTask starts an agent session for a newly created task via WebSocket. +func (h *TaskHandlers) launchAgentForNewTask(ctx context.Context, taskDTO dto.TaskDTO, req wsCreateTaskRequest) (*orchestrator.LaunchSessionResponse, error) { + launchResp, err := h.orchestrator.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{ + TaskID: taskDTO.ID, + Intent: orchestrator.IntentStart, + AgentProfileID: req.AgentProfileID, + ExecutorID: req.ExecutorID, + ExecutorProfileID: req.ExecutorProfileID, + Priority: req.Priority, + Prompt: taskDTO.Description, + WorkflowStepID: taskDTO.WorkflowStepID, + PlanMode: req.PlanMode, + Attachments: req.Attachments, + }) + if err != nil { + return nil, err + } + h.logger.Info("wsCreateTask started agent", + zap.String("task_id", taskDTO.ID), + zap.String("executor_id", req.ExecutorID), + zap.String("workflow_step_id", taskDTO.WorkflowStepID), + zap.String("session_id", launchResp.SessionID)) + return launchResp, nil +} + type wsGetTaskRequest struct { ID string `json:"id"` } diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index bf3a10639c..7e7d9e4796 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -10,11 +10,30 @@ import { useContextFilesStore } from "@/lib/state/context-files-store"; import { linkToSession } from "@/lib/links"; import { INTENT_PLAN } from "@/lib/state/layout-manager"; import { createTask } from "@/lib/api"; +import type { FileAttachment } from "@/components/task/chat/file-attachment"; +import type { MessageAttachment } from "@/lib/services/session-launch-service"; type CreateTaskParams = Parameters[0]; export type { CreateTaskParams }; +/** Converts FileAttachment array to MessageAttachment array for the launch request. */ +export function toMessageAttachments( + attachments: FileAttachment[], +): MessageAttachment[] | undefined { + if (attachments.length === 0) return undefined; + return attachments.map((att) => + att.isImage + ? { type: "image" as const, data: att.data, mime_type: att.mimeType } + : { + type: "resource" as const, + data: att.data, + mime_type: att.mimeType, + name: att.fileName, + }, + ); +} + export function autoSelectBranch(branchList: Branch[], setBranch: (value: string) => void): void { const lastUsedBranch = getLocalStorage(STORAGE_KEYS.LAST_BRANCH, null); if ( @@ -92,6 +111,7 @@ export type BuildCreatePayloadArgs = { executorProfileId: string; withAgent: boolean; planMode?: boolean; + attachments?: MessageAttachment[]; }; export function buildCreateTaskPayload(args: BuildCreatePayloadArgs): CreateTaskParams { @@ -108,6 +128,7 @@ export function buildCreateTaskPayload(args: BuildCreatePayloadArgs): CreateTask executor_id: args.executorId || undefined, executor_profile_id: args.executorProfileId || undefined, plan_mode: args.planMode || undefined, + attachments: args.attachments, }; } @@ -128,3 +149,38 @@ export function validateCreateInputs(inputs: { (inputs.repositoryId || inputs.selectedLocalRepo || inputs.githubUrl?.trim()), ); } + +/** Builds the repositories payload for task creation from the current form state. */ +export function buildRepositoriesPayload(opts: { + useGitHubUrl: boolean; + githubUrl: string; + branch: string; + githubPrHeadBranch: string | null; + repositoryId: string; + selectedLocalRepo: LocalRepository | null; +}): NonNullable { + if (opts.useGitHubUrl && opts.githubUrl) { + return [ + { + repository_id: "", + base_branch: opts.branch || undefined, + checkout_branch: opts.githubPrHeadBranch || undefined, + github_url: opts.githubUrl.trim(), + }, + ]; + } + if (opts.repositoryId) { + return [{ repository_id: opts.repositoryId, base_branch: opts.branch || undefined }]; + } + if (opts.selectedLocalRepo) { + return [ + { + repository_id: "", + base_branch: opts.branch || undefined, + local_path: opts.selectedLocalRepo.path, + default_branch: opts.selectedLocalRepo.default_branch || undefined, + }, + ]; + } + return []; +} diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index 341a8fc8b3..7aad1e58b3 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -1,8 +1,20 @@ "use client"; -import { useEffect, useRef, useState, memo, useCallback } from "react"; +import { useEffect, useRef, useState, memo, useCallback, useMemo } from "react"; import { Textarea } from "@kandev/ui/textarea"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { IconPaperclip } from "@tabler/icons-react"; import { Combobox } from "./combobox"; +import { + processFile, + formatBytes, + MAX_FILES, + MAX_TOTAL_SIZE, + type FileAttachment, +} from "@/components/task/chat/file-attachment"; +import { ContextZone } from "@/components/task/chat/context-items/context-zone"; +import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context"; +import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types"; const CURSOR_POINTER_CLASS = "cursor-pointer"; @@ -233,32 +245,181 @@ type TaskFormInputsProps = { initialDescription: string; onDescriptionChange: (hasContent: boolean) => void; onKeyDown: (e: React.KeyboardEvent) => void; - descriptionValueRef: React.RefObject<{ getValue: () => string } | null>; + descriptionValueRef: React.RefObject; disabled?: boolean; placeholder?: string; }; -export const TaskFormInputs = memo(function TaskFormInputs({ - isSessionMode, - autoFocus, - initialDescription, - onDescriptionChange, - onKeyDown, - descriptionValueRef, - disabled, - placeholder, -}: TaskFormInputsProps) { +function useFileAttachments() { + const [attachments, setAttachments] = useState([]); + const [isDragging, setIsDragging] = useState(false); + + const addFiles = useCallback(async (files: File[]) => { + const processed: FileAttachment[] = []; + for (const file of files) { + const attachment = await processFile(file); + if (attachment) processed.push(attachment); + } + if (processed.length === 0) return; + + setAttachments((prev) => { + let nextCount = prev.length; + let nextTotalSize = prev.reduce((sum, att) => sum + att.size, 0); + const accepted: FileAttachment[] = []; + for (const att of processed) { + if (nextCount >= MAX_FILES) break; + if (nextTotalSize + att.size > MAX_TOTAL_SIZE) break; + accepted.push(att); + nextCount += 1; + nextTotalSize += att.size; + } + if (nextCount >= MAX_FILES && accepted.length < processed.length) { + console.warn(`Maximum ${MAX_FILES} files allowed`); + } else if (accepted.length < processed.length) { + console.warn(`Total attachment size limit exceeded (max: ${formatBytes(MAX_TOTAL_SIZE)})`); + } + return accepted.length > 0 ? [...prev, ...accepted] : prev; + }); + }, []); + + const handleRemoveAttachment = useCallback((id: string) => { + setAttachments((prev) => prev.filter((att) => att.id !== id)); + }, []); + + return { attachments, isDragging, setIsDragging, addFiles, handleRemoveAttachment }; +} + +function useAttachmentHandlers( + disabled: boolean | undefined, + addFiles: (files: File[]) => Promise, + setIsDragging: (v: boolean) => void, +) { + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + if (disabled) return; + const items = e.clipboardData?.items; + if (!items) return; + const files: File[] = []; + for (const item of items) { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) files.push(file); + } + } + if (files.length > 0) { + e.preventDefault(); + void addFiles(files); + } + }, + [disabled, addFiles], + ); + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + if (disabled) return; + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }, + [disabled, setIsDragging], + ); + + const handleDragLeave = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + const { clientX, clientY } = e; + if ( + clientX <= rect.left || + clientX >= rect.right || + clientY <= rect.top || + clientY >= rect.bottom + ) { + setIsDragging(false); + } + }, + [setIsDragging], + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + if (disabled) return; + const files = Array.from(e.dataTransfer.files).filter((f) => f.size > 0 || f.type !== ""); + if (files.length > 0) { + void addFiles(files); + } + }, + [disabled, addFiles, setIsDragging], + ); + + return { handlePaste, handleDragOver, handleDragLeave, handleDrop }; +} + +function toContextItems( + attachments: FileAttachment[], + onRemove: (id: string) => void, +): ContextItem[] { + return attachments.map((att) => + att.isImage + ? ({ + kind: "image" as const, + id: `image:${att.id}`, + label: `Image (${formatBytes(att.size)})`, + attachment: att, + onRemove: () => onRemove(att.id), + } as ImageContextItem) + : ({ + kind: "file-attachment" as const, + id: `file:${att.id}`, + label: att.fileName, + attachment: att, + onRemove: () => onRemove(att.id), + } as FileAttachmentContextItem), + ); +} + +function AttachButton({ onClick, disabled }: { onClick: () => void; disabled?: boolean }) { + return ( +
+ + + + + Attach files + +
+ ); +} + +function useDescriptionInput( + initialDescription: string, + autoFocus: boolean | undefined, + descriptionValueRef: React.RefObject, + onDescriptionChange: (hasContent: boolean) => void, + attachments: FileAttachment[], +) { const [description, setDescription] = useState(initialDescription); const textareaRef = useRef(null); useEffect(() => { - const ref = descriptionValueRef as React.MutableRefObject<{ getValue: () => string } | null>; + const ref = descriptionValueRef as React.MutableRefObject; if (ref) { - ref.current = { getValue: () => description }; + ref.current = { getValue: () => description, getAttachments: () => attachments }; } - }, [description, descriptionValueRef]); + }, [description, attachments, descriptionValueRef]); - // Auto-resize textarea + optional auto-focus with cursor at end useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; @@ -280,36 +441,97 @@ export const TaskFormInputs = memo(function TaskFormInputs({ const hadContent = description.trim().length > 0; const hasContent = newValue.trim().length > 0; setDescription(newValue); - if (hadContent !== hasContent) { - onDescriptionChange(hasContent); - } + if (hadContent !== hasContent) onDescriptionChange(hasContent); }, [description, onDescriptionChange], ); + return { description, textareaRef, handleDescriptionChange }; +} + +export const TaskFormInputs = memo(function TaskFormInputs({ + isSessionMode, + autoFocus, + initialDescription, + onDescriptionChange, + onKeyDown, + descriptionValueRef, + disabled, + placeholder, +}: TaskFormInputsProps) { + const fileInputRef = useRef(null); + const { attachments, isDragging, setIsDragging, addFiles, handleRemoveAttachment } = + useFileAttachments(); + const { handlePaste, handleDragOver, handleDragLeave, handleDrop } = useAttachmentHandlers( + disabled, + addFiles, + setIsDragging, + ); + const contextItems = useMemo( + () => toContextItems(attachments, handleRemoveAttachment), + [attachments, handleRemoveAttachment], + ); + const { description, textareaRef, handleDescriptionChange } = useDescriptionInput( + initialDescription, + autoFocus, + descriptionValueRef, + onDescriptionChange, + attachments, + ); + const handleAttachClick = useCallback(() => fileInputRef.current?.click(), []); + const handleFileInputChange = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) void addFiles(Array.from(files)); + e.target.value = ""; + }, + [addFiles], + ); + return ( -
-