feat(web): add image and file paste/drop support to task creation dialog (#453)

This commit is contained in:
João Salavisa
2026-03-23 23:51:14 +00:00
committed by GitHub
parent cc23c9b0c8
commit 7f956d2f82
24 changed files with 673 additions and 239 deletions
+20 -2
View File
@@ -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
}
@@ -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)
@@ -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)
}
@@ -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),
@@ -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
}, "")
+13 -4
View File
@@ -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)
@@ -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",
@@ -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),
@@ -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
@@ -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),
@@ -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
@@ -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),
@@ -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 {
@@ -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),
@@ -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))
@@ -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"`
}
@@ -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<typeof createTask>[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<string | null>(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<CreateTaskParams["repositories"]> {
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 [];
}
@@ -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<TaskFormInputsHandle | null>;
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<FileAttachment[]>([]);
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<void>,
setIsDragging: (v: boolean) => void,
) {
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
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 (
<div className="flex items-center px-1 pb-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Attach files"
className={`h-7 w-7 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-muted/40 hover:text-foreground ${disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
onClick={onClick}
disabled={disabled}
>
<IconPaperclip className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Attach files</TooltipContent>
</Tooltip>
</div>
);
}
function useDescriptionInput(
initialDescription: string,
autoFocus: boolean | undefined,
descriptionValueRef: React.RefObject<TaskFormInputsHandle | null>,
onDescriptionChange: (hasContent: boolean) => void,
attachments: FileAttachment[],
) {
const [description, setDescription] = useState(initialDescription);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const ref = descriptionValueRef as React.MutableRefObject<{ getValue: () => string } | null>;
const ref = descriptionValueRef as React.MutableRefObject<TaskFormInputsHandle | null>;
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<HTMLInputElement>(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<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) void addFiles(Array.from(files));
e.target.value = "";
},
[addFiles],
);
return (
<div>
<Textarea
ref={textareaRef}
placeholder={
placeholder ??
(isSessionMode
? "Describe what you want the agent to do..."
: "Write a prompt for the agent...")
}
value={description}
onChange={handleDescriptionChange}
onKeyDown={onKeyDown}
data-testid="task-description-input"
rows={2}
className={
isSessionMode
? "min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]"
: "min-h-[96px] max-h-[240px] resize-y overflow-auto text-[13px]"
}
required={isSessionMode}
disabled={disabled}
/>
<div
className="relative"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div
className={`rounded-md border border-input bg-transparent ${contextItems.length > 0 ? "ring-0" : ""}`}
>
<ContextZone items={contextItems} />
<Textarea
ref={textareaRef}
placeholder={
placeholder ??
(isSessionMode
? "Describe what you want the agent to do..."
: "Write a prompt for the agent...")
}
value={description}
onChange={handleDescriptionChange}
onKeyDown={onKeyDown}
onPaste={handlePaste}
data-testid="task-description-input"
rows={2}
className={`border-0 focus-visible:ring-0 focus-visible:ring-offset-0 ${isSessionMode ? "min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]" : "min-h-[96px] max-h-[240px] resize-y overflow-auto text-[13px]"}`}
required={isSessionMode}
disabled={disabled}
/>
<AttachButton onClick={handleAttachClick} disabled={disabled} />
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileInputChange}
tabIndex={-1}
/>
</div>
{isDragging && (
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 border-2 border-dashed border-primary rounded-md pointer-events-none">
<span className="text-sm text-primary font-medium">Drop files here</span>
</div>
)}
</div>
);
});
@@ -8,6 +8,7 @@ import type {
ExecutorProfile,
Branch,
} from "@/lib/types/http";
import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types";
import { useAppStore } from "@/components/state-provider";
import { useRepositories } from "@/hooks/domains/workspace/use-repositories";
import { useRepositoryBranches } from "@/hooks/domains/workspace/use-repository-branches";
@@ -261,7 +262,7 @@ function useFormStateValues(
const [hasDescription, setHasDescription] = useState(false);
const [draftDescription, setDraftDescription] = useState("");
const descriptionInputRef = useRef<{ getValue: () => string } | null>(null);
const descriptionInputRef = useRef<TaskFormInputsHandle | null>(null);
const [repositoryId, setRepositoryId] = useState(initialValues?.repositoryId ?? "");
const [branch, setBranch] = useState(initialValues?.branch ?? "");
const [agentProfileId, setAgentProfileId] = useState("");
@@ -2,72 +2,25 @@
import { useCallback, FormEvent } from "react";
import { useRouter } from "next/navigation";
import type { Task, LocalRepository } from "@/lib/types/http";
import { createTask, updateTask } from "@/lib/api";
import { useAppStore } from "@/components/state-provider";
import { launchSession } from "@/lib/services/session-launch-service";
import { buildStartRequest } from "@/lib/services/session-launch-helpers";
import { useToast } from "@/components/toast-provider";
import { linkToSession } from "@/lib/links";
import type { SubmitHandlersDeps } from "@/components/task-create-dialog-types";
import {
activatePlanMode,
buildCreateTaskPayload,
buildRepositoriesPayload,
validateCreateInputs,
toMessageAttachments,
type CreateTaskParams,
} from "@/components/task-create-dialog-helpers";
const GENERIC_ERROR_MESSAGE = "An error occurred";
export type SubmitHandlersDeps = {
isSessionMode: boolean;
isEditMode: boolean;
isPassthroughProfile: boolean;
taskName: string;
workspaceId: string | null;
workflowId: string | null;
effectiveWorkflowId: string | null;
effectiveDefaultStepId: string | null;
repositoryId: string;
selectedLocalRepo: LocalRepository | null;
useGitHubUrl: boolean;
githubUrl: string;
githubPrHeadBranch: string | null;
branch: string;
agentProfileId: string;
executorId: string;
executorProfileId: string;
editingTask?: {
id: string;
title: string;
description?: string;
workflowStepId: string;
state?: Task["state"];
repositoryId?: string;
} | null;
onSuccess?: (
task: Task,
mode: "create" | "edit",
meta?: { taskSessionId?: string | null },
) => void;
onCreateSession?: (data: { prompt: string; agentProfileId: string; executorId: string }) => void;
onOpenChange: (open: boolean) => void;
taskId: string | null;
descriptionInputRef: React.RefObject<{ getValue: () => string } | null>;
setIsCreatingSession: (v: boolean) => void;
setIsCreatingTask: (v: boolean) => void;
setHasTitle: (v: boolean) => void;
setHasDescription: (v: boolean) => void;
setTaskName: (v: string) => void;
setRepositoryId: (v: string) => void;
setBranch: (v: string) => void;
setAgentProfileId: (v: string) => void;
setExecutorId: (v: string) => void;
setSelectedWorkflowId: (v: string | null) => void;
setFetchedSteps: (v: null) => void;
clearDraft: () => void;
};
// eslint-disable-next-line max-lines-per-function
export function useTaskSubmitHandlers({
isSessionMode,
@@ -135,36 +88,23 @@ export function useTaskSubmitHandlers({
setFetchedSteps,
]);
const getRepositoriesPayload = useCallback(() => {
if (useGitHubUrl && githubUrl) {
return [
{
repository_id: "",
base_branch: branch || undefined,
checkout_branch: githubPrHeadBranch || undefined,
github_url: githubUrl,
},
];
}
if (repositoryId) {
return [{ repository_id: repositoryId, base_branch: branch || undefined }];
}
if (selectedLocalRepo) {
return [
{
repository_id: "",
base_branch: branch || undefined,
local_path: selectedLocalRepo.path,
default_branch: selectedLocalRepo.default_branch || undefined,
},
];
}
return [];
}, [useGitHubUrl, repositoryId, branch, githubUrl, githubPrHeadBranch, selectedLocalRepo]);
const getRepositoriesPayload = useCallback(
() =>
buildRepositoriesPayload({
useGitHubUrl,
githubUrl,
branch,
githubPrHeadBranch,
repositoryId,
selectedLocalRepo,
}),
[useGitHubUrl, repositoryId, branch, githubUrl, githubPrHeadBranch, selectedLocalRepo],
);
const handleSessionSubmit = useCallback(async () => {
const description = descriptionInputRef.current?.getValue() ?? "";
const trimmedDescription = description.trim();
const attachments = descriptionInputRef.current?.getAttachments() ?? [];
if (!agentProfileId) return;
if (!trimmedDescription && !isPassthroughProfile) return;
@@ -182,6 +122,7 @@ export function useTaskSubmitHandlers({
executorId,
executorProfileId: executorProfileId || undefined,
prompt: trimmedDescription,
attachments: toMessageAttachments(attachments),
});
const response = await launchSession(request);
@@ -299,6 +240,7 @@ export function useTaskSubmitHandlers({
trimmedDescription: string,
repositoriesPayload: CreateTaskParams["repositories"],
planMode?: boolean,
attachments?: ReturnType<typeof toMessageAttachments>,
) => {
if (!workspaceId || !effectiveWorkflowId) return;
const taskResponse = await createTask(
@@ -313,6 +255,7 @@ export function useTaskSubmitHandlers({
executorProfileId,
withAgent: true,
planMode,
attachments,
}),
);
const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null;
@@ -437,6 +380,9 @@ export function useTaskSubmitHandlers({
const trimmedTitle = taskName.trim();
const description = descriptionInputRef.current?.getValue() ?? "";
const trimmedDescription = description.trim();
const attachments = toMessageAttachments(
descriptionInputRef.current?.getAttachments() ?? [],
);
if (
!validateCreateInputs({
trimmedTitle,
@@ -454,6 +400,7 @@ export function useTaskSubmitHandlers({
trimmedDescription,
getRepositoriesPayload(),
true,
attachments,
);
}
} catch (error) {
@@ -486,6 +433,7 @@ export function useTaskSubmitHandlers({
const trimmedTitle = taskName.trim();
const description = descriptionInputRef.current?.getValue() ?? "";
const trimmedDescription = description.trim();
const attachments = toMessageAttachments(descriptionInputRef.current?.getAttachments() ?? []);
if (
!validateCreateInputs({
trimmedTitle,
@@ -502,7 +450,13 @@ export function useTaskSubmitHandlers({
setIsCreatingTask(true);
try {
if (trimmedDescription || isPassthroughProfile) {
await performCreateWithAgent(trimmedTitle, trimmedDescription, repositoriesPayload);
await performCreateWithAgent(
trimmedTitle,
trimmedDescription,
repositoriesPayload,
undefined,
attachments,
);
} else {
await handleCreatePlanMode(trimmedTitle, repositoriesPayload);
}
@@ -534,8 +488,7 @@ export function useTaskSubmitHandlers({
const handleCreateWithoutAgent = useCallback(async () => {
const trimmedTitle = taskName.trim();
const description = descriptionInputRef.current?.getValue() ?? "";
const trimmedDescription = description.trim();
const trimmedDescription = (descriptionInputRef.current?.getValue() ?? "").trim();
if (
!validateCreateInputs({
trimmedTitle,
@@ -548,25 +501,24 @@ export function useTaskSubmitHandlers({
})
)
return;
if (!trimmedDescription) return;
const stepId = effectiveDefaultStepId;
if (!stepId || !workspaceId || !effectiveWorkflowId) return;
if (!trimmedDescription || !effectiveDefaultStepId || !workspaceId || !effectiveWorkflowId)
return;
setIsCreatingTask(true);
try {
const reposPayload = getRepositoriesPayload();
const taskResponse = await createTask({
workspace_id: workspaceId,
workflow_id: effectiveWorkflowId,
workflow_step_id: stepId,
title: trimmedTitle,
description: trimmedDescription,
repositories: reposPayload,
state: "CREATED",
agent_profile_id: agentProfileId || undefined,
executor_id: executorId || undefined,
executor_profile_id: executorProfileId || undefined,
const payload = buildCreateTaskPayload({
workspaceId,
effectiveWorkflowId,
trimmedTitle,
trimmedDescription,
repositoriesPayload: getRepositoriesPayload(),
agentProfileId,
executorId,
executorProfileId,
withAgent: false,
});
payload.workflow_step_id = effectiveDefaultStepId;
const taskResponse = await createTask(payload);
onSuccess?.(taskResponse, "create");
clearDraft();
onOpenChange(false);
@@ -80,6 +80,13 @@ export type TaskCreateEffectsArgs = {
toast: ReturnType<typeof useToast>["toast"];
};
import type { FileAttachment } from "@/components/task/chat/file-attachment";
export type TaskFormInputsHandle = {
getValue: () => string;
getAttachments: () => FileAttachment[];
};
export type DialogFormState = {
taskName: string;
setTaskName: (v: string) => void;
@@ -93,7 +100,7 @@ export type DialogFormState = {
openCycle: number;
/** Computed defaults for current open cycle (includes draft restoration) */
currentDefaults: { name: string; description: string };
descriptionInputRef: import("react").RefObject<{ getValue: () => string } | null>;
descriptionInputRef: import("react").RefObject<TaskFormInputsHandle | null>;
repositoryId: string;
setRepositoryId: (v: string) => void;
branch: string;
@@ -141,3 +148,52 @@ export type DialogFormState = {
/** Clear draft on successful submission (before closing dialog) */
clearDraft: () => void;
};
export type SubmitHandlersDeps = {
isSessionMode: boolean;
isEditMode: boolean;
isPassthroughProfile: boolean;
taskName: string;
workspaceId: string | null;
workflowId: string | null;
effectiveWorkflowId: string | null;
effectiveDefaultStepId: string | null;
repositoryId: string;
selectedLocalRepo: LocalRepository | null;
useGitHubUrl: boolean;
githubUrl: string;
githubPrHeadBranch: string | null;
branch: string;
agentProfileId: string;
executorId: string;
executorProfileId: string;
editingTask?: {
id: string;
title: string;
description?: string;
workflowStepId: string;
state?: Task["state"];
repositoryId?: string;
} | null;
onSuccess?: (
task: Task,
mode: "create" | "edit",
meta?: { taskSessionId?: string | null },
) => void;
onCreateSession?: (data: { prompt: string; agentProfileId: string; executorId: string }) => void;
onOpenChange: (open: boolean) => void;
taskId: string | null;
descriptionInputRef: React.RefObject<TaskFormInputsHandle | null>;
setIsCreatingSession: (v: boolean) => void;
setIsCreatingTask: (v: boolean) => void;
setHasTitle: (v: boolean) => void;
setHasDescription: (v: boolean) => void;
setTaskName: (v: string) => void;
setRepositoryId: (v: string) => void;
setBranch: (v: string) => void;
setAgentProfileId: (v: string) => void;
setExecutorId: (v: string) => void;
setSelectedWorkflowId: (v: string | null) => void;
setFetchedSteps: (v: null) => void;
clearDraft: () => void;
};
+2
View File
@@ -33,6 +33,7 @@ export async function createTask(
repositories?: Array<{
repository_id: string;
base_branch?: string;
checkout_branch?: string;
local_path?: string;
name?: string;
default_branch?: string;
@@ -45,6 +46,7 @@ export async function createTask(
executor_id?: string;
executor_profile_id?: string;
plan_mode?: boolean;
attachments?: Array<{ type: string; data: string; mime_type: string; name?: string }>;
},
options?: ApiRequestOptions,
) {
@@ -1,4 +1,4 @@
import type { LaunchSessionRequest } from "./session-launch-service";
import type { LaunchSessionRequest, MessageAttachment } from "./session-launch-service";
export type LayoutIntentHint = "default" | "plan" | "pr-review" | "keep";
@@ -16,6 +16,7 @@ export function buildStartRequest(
prompt?: string;
planMode?: boolean;
priority?: number;
attachments?: MessageAttachment[];
},
): BuildResult {
return {
@@ -28,6 +29,7 @@ export function buildStartRequest(
prompt: opts?.prompt,
plan_mode: opts?.planMode,
priority: opts?.priority,
attachments: opts?.attachments,
},
layout: opts?.planMode ? "plan" : "default",
};
@@ -8,6 +8,13 @@ export type SessionIntent =
| "workflow_step"
| "restore_workspace";
export type MessageAttachment = {
type: "image" | "audio" | "resource";
data: string;
mime_type: string;
name?: string;
};
export type LaunchSessionRequest = {
task_id: string;
intent?: SessionIntent;
@@ -21,6 +28,7 @@ export type LaunchSessionRequest = {
priority?: number;
launch_workspace?: boolean;
skip_message_record?: boolean;
attachments?: MessageAttachment[];
};
export type LaunchSessionResponse = {