feat(web): add image and file paste/drop support to task creation dialog (#453)
This commit is contained in:
@@ -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
|
||||
}, "")
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user