From 63efc85dd131d78ffdb632cff5064608c0b98824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Sun, 15 Mar 2026 10:50:50 +0000 Subject: [PATCH] feat: make quick chats independent of workflows (#434) --- .../agent/settings/controller/controller.go | 1 + .../agent/settings/controller/profile_crud.go | 51 +++++++++-- .../orchestrator/event_handlers_workflow.go | 11 +++ .../orchestrator/executor/executor_test.go | 3 + .../internal/orchestrator/task_operations.go | 13 +-- .../orchestrator/workflow_prompt_test.go | 1 + .../task/handlers/process_handlers_test.go | 3 + .../task/handlers/task_http_handlers.go | 34 +------- .../internal/task/repository/interface.go | 1 + .../internal/task/repository/sqlite/base.go | 84 ++++++++++++++++++- .../task/repository/sqlite/session.go | 27 +++++- .../internal/task/service/service_tasks.go | 13 ++- apps/web/app/s/[sessionId]/page.tsx | 6 +- apps/web/components/global-commands.tsx | 11 ++- .../quick-chat/quick-chat-content.tsx | 10 +++ .../components/task/chat/chat-input-area.tsx | 4 + .../components/task/chat/chat-input-body.tsx | 4 +- .../task/chat/chat-input-container.tsx | 3 + .../task/chat/chat-input-toolbar.tsx | 15 ++-- .../web/components/task/quick-chat-button.tsx | 7 +- apps/web/lib/keyboard/shortcut-overrides.ts | 4 +- apps/web/lib/ssr/mapper.ts | 12 +++ 22 files changed, 254 insertions(+), 64 deletions(-) diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index a1c8c9aae1..4894327030 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -62,6 +62,7 @@ func (e *ErrProfileInUseDetail) Error() string { type SessionChecker interface { HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) + DeleteEphemeralTasksByAgentProfile(ctx context.Context, agentProfileID string) (int64, error) GetActiveTaskInfoByAgentProfile(ctx context.Context, agentProfileID string) ([]agentdto.ActiveTaskInfo, error) } diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go index c991b00b27..675f06bb19 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" + "go.uber.org/zap" + "github.com/kandev/kandev/internal/agent/settings/dto" "github.com/kandev/kandev/internal/agent/settings/models" ) @@ -113,14 +115,8 @@ func (c *Controller) DeleteProfile(ctx context.Context, id string, force bool) ( } return nil, err } - if !force && c.sessionChecker != nil { - activeTasks, err := c.sessionChecker.GetActiveTaskInfoByAgentProfile(ctx, id) - if err != nil { - return nil, err - } - if len(activeTasks) > 0 { - return nil, &ErrProfileInUseDetail{ActiveSessions: activeTasks} - } + if err := c.prepareProfileDeletion(ctx, id, force); err != nil { + return nil, err } if err := c.repo.DeleteAgentProfile(ctx, id); err != nil { if strings.Contains(err.Error(), "agent profile not found") { @@ -132,6 +128,45 @@ func (c *Controller) DeleteProfile(ctx context.Context, id string, force bool) ( return &result, nil } +// prepareProfileDeletion checks for active sessions and cleans up ephemeral tasks before deletion. +func (c *Controller) prepareProfileDeletion(ctx context.Context, profileID string, force bool) error { + if c.sessionChecker == nil { + return nil + } + // Check for active non-ephemeral sessions (unless force is true) + // This allows the UI to show a confirmation dialog with affected tasks + if !force { + activeTasks, err := c.sessionChecker.GetActiveTaskInfoByAgentProfile(ctx, profileID) + if err != nil { + return err + } + if len(activeTasks) > 0 { + return &ErrProfileInUseDetail{ActiveSessions: activeTasks} + } + } + // Clean up ephemeral tasks (quick chat, config chat) using this profile + // Done after the force check since these don't need user confirmation + c.cleanupEphemeralTasks(ctx, profileID) + return nil +} + +// cleanupEphemeralTasks removes ephemeral tasks (quick chat, config chat) associated with a profile. +func (c *Controller) cleanupEphemeralTasks(ctx context.Context, profileID string) { + if c.sessionChecker == nil { + return + } + deleted, err := c.sessionChecker.DeleteEphemeralTasksByAgentProfile(ctx, profileID) + if err != nil { + c.logger.Warn("failed to delete ephemeral tasks for profile", + zap.String("profile_id", profileID), zap.Error(err)) + return + } + if deleted > 0 { + c.logger.Info("deleted ephemeral tasks for profile deletion", + zap.String("profile_id", profileID), zap.Int64("count", deleted)) + } +} + func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentDTO { profileDTOs := make([]dto.AgentProfileDTO, 0, len(profiles)) for _, profile := range profiles { diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index b1486c567c..f6e4df31ef 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -898,6 +898,12 @@ func (s *Service) processOnTurnCompleteViaEngine(ctx context.Context, taskID str return false } + // Skip workflow step actions for ephemeral tasks (quick chat) - they have no workflow + if task.IsEphemeral { + s.setSessionWaitingForInput(ctx, taskID, session.ID, session) + return false + } + state := s.buildMachineState(ctx, task, session) result, err := s.workflowEngine.HandleTrigger(ctx, engine.HandleInput{ TaskID: taskID, @@ -1023,6 +1029,11 @@ func (s *Service) processOnTurnStartViaEngine(ctx context.Context, taskID string return false } + // Skip workflow step actions for ephemeral tasks (quick chat) - they have no workflow + if task.IsEphemeral { + return false + } + state := s.buildMachineState(ctx, task, session) result, err := s.workflowEngine.HandleTrigger(ctx, engine.HandleInput{ TaskID: taskID, diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 59468f032b..161fc5be67 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -422,6 +422,9 @@ func (m *mockRepository) HasActiveTaskSessionsByEnvironment(ctx context.Context, func (m *mockRepository) HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) { return false, nil } +func (m *mockRepository) DeleteEphemeralTasksByAgentProfile(ctx context.Context, agentProfileID string) (int64, error) { + return 0, nil +} func (m *mockRepository) DeleteTaskSession(ctx context.Context, id string) error { return nil } // Workflow-related session operations diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index c2f9af9d36..ba740a5c1f 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -194,7 +194,7 @@ func (s *Service) StartTaskWithSession(ctx context.Context, taskID string, sessi effectivePrompt = task.Description } - effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, task.ID, sessionID, workflowStepID, planMode) + effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, task.ID, sessionID, workflowStepID, planMode, task.IsEphemeral) execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, executor.LaunchOptions{AgentProfileID: agentProfileID, ExecutorID: executorID, Prompt: effectivePrompt, WorkflowStepID: workflowStepID, StartAgent: true}) if err != nil { @@ -283,11 +283,12 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag // Apply workflow step prompt wrapping and plan mode injection. // Called unconditionally so workflow-step prompt composition (prefix/suffix) // applies even when plan mode is not requested. + // Ephemeral tasks skip workflow step processing since they have no workflow. stepID := "" if session.WorkflowStepID != nil { stepID = *session.WorkflowStepID } - effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, taskID, sessionID, stepID, planMode) + effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, taskID, sessionID, stepID, planMode, task.IsEphemeral) // Inject config context for config-mode sessions (dedicated settings chat) if cm, ok := session.Metadata["config_mode"].(bool); ok && cm { @@ -395,7 +396,7 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s return nil, err } - effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, task.ID, sessionID, workflowStepID, planMode) + effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, task.ID, sessionID, workflowStepID, planMode, task.IsEphemeral) // Inject config context for config-mode sessions (dedicated settings chat) configMode := false @@ -440,11 +441,13 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s // applyWorkflowAndPlanMode applies workflow step configuration and plan mode injection to a prompt. // Returns the effective prompt and whether plan mode is active (from either the step or the caller). -func (s *Service) applyWorkflowAndPlanMode(ctx context.Context, prompt string, taskID string, sessionID string, workflowStepID string, planMode bool) (string, bool) { +// For ephemeral tasks (quick chat), workflow step processing is skipped since they have no workflow. +func (s *Service) applyWorkflowAndPlanMode(ctx context.Context, prompt string, taskID string, sessionID string, workflowStepID string, planMode bool, isEphemeral bool) (string, bool) { effectivePrompt := prompt stepHasPlanMode := false - if workflowStepID != "" && s.workflowStepGetter != nil { + // Skip workflow step prompt injection for ephemeral tasks - they don't have workflows + if !isEphemeral && workflowStepID != "" && s.workflowStepGetter != nil { step, err := s.workflowStepGetter.GetStep(ctx, workflowStepID) if err != nil { s.logger.Warn("failed to get workflow step for prompt building", diff --git a/apps/backend/internal/orchestrator/workflow_prompt_test.go b/apps/backend/internal/orchestrator/workflow_prompt_test.go index 24641f87b8..289e46b4b1 100644 --- a/apps/backend/internal/orchestrator/workflow_prompt_test.go +++ b/apps/backend/internal/orchestrator/workflow_prompt_test.go @@ -57,6 +57,7 @@ func TestApplyWorkflowAndPlanMode_KeepsWorkflowPromptVisibleWhenStepEnablesPlanM "session-1", "step-1", false, + false, // isEphemeral ) if !planModeActive { diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 81e6a43da9..060bf19b6b 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -219,6 +219,9 @@ func (m *mockRepository) HasActiveTaskSessionsByEnvironment(ctx context.Context, func (m *mockRepository) HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) { return false, nil } +func (m *mockRepository) DeleteEphemeralTasksByAgentProfile(ctx context.Context, agentProfileID string) (int64, error) { + return 0, nil +} func (m *mockRepository) DeleteTaskSession(ctx context.Context, id string) error { return nil } diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 4aa6fb8a70..d02d85a39c 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -638,7 +638,6 @@ type httpStartQuickChatResponse struct { // quickChatParams holds resolved parameters for creating a quick chat session. type quickChatParams struct { - workflowID string agentProfileID string executorID string title string @@ -660,10 +659,8 @@ func (body *httpStartQuickChatRequest) buildRepositories() []service.TaskReposit }} } -// resolveQuickChatParams resolves agent/executor IDs and builds metadata. -func (body *httpStartQuickChatRequest) resolveParams( - workspace *models.Workspace, workflowID string, -) quickChatParams { +// resolveParams resolves agent/executor IDs and builds metadata for quick chat. +func (body *httpStartQuickChatRequest) resolveParams(workspace *models.Workspace) quickChatParams { agentProfileID := body.AgentProfileID if agentProfileID == "" && workspace.DefaultAgentProfileID != nil { agentProfileID = *workspace.DefaultAgentProfileID @@ -687,7 +684,6 @@ func (body *httpStartQuickChatRequest) resolveParams( } return quickChatParams{ - workflowID: workflowID, agentProfileID: agentProfileID, executorID: executorID, title: title, @@ -713,18 +709,7 @@ func (h *TaskHandlers) httpStartQuickChat(c *gin.Context) { return } - workflows, err := h.service.ListWorkflows(ctx, workspaceID) - if err != nil { - h.logger.Error("failed to list workflows", zap.Error(err), zap.String("workspace_id", workspaceID)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workflows"}) - return - } - if len(workflows) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "workspace has no workflows"}) - return - } - - params := body.resolveParams(workspace, workflows[0].ID) + params := body.resolveParams(workspace) if params.agentProfileID == "" { h.logger.Error("no agent profile configured for quick chat", zap.String("workspace_id", workspaceID)) c.JSON(http.StatusBadRequest, gin.H{"error": "workspace has no default agent profile configured"}) @@ -733,7 +718,6 @@ func (h *TaskHandlers) httpStartQuickChat(c *gin.Context) { task, err := h.service.CreateTask(ctx, &service.CreateTaskRequest{ WorkspaceID: workspaceID, - WorkflowID: params.workflowID, Title: params.title, Description: body.Prompt, Repositories: params.repos, @@ -826,17 +810,6 @@ func (h *TaskHandlers) httpStartConfigChat(c *gin.Context) { return } - workflows, err := h.service.ListWorkflows(ctx, workspaceID) - if err != nil { - h.logger.Error("failed to list workflows", zap.Error(err), zap.String("workspace_id", workspaceID)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workflows"}) - return - } - if len(workflows) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "workspace has no workflows"}) - return - } - agentProfileID, executorID, metadata := resolveConfigChatDefaults(body, workspace) if agentProfileID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "no agent profile configured — set a default agent profile in workspace settings"}) @@ -845,7 +818,6 @@ func (h *TaskHandlers) httpStartConfigChat(c *gin.Context) { task, err := h.service.CreateTask(ctx, &service.CreateTaskRequest{ WorkspaceID: workspaceID, - WorkflowID: workflows[0].ID, Title: "Config Chat", Description: body.Prompt, IsEphemeral: true, diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index df24897dd8..2a43fa3449 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -99,6 +99,7 @@ type SessionRepository interface { HasActiveTaskSessionsByExecutor(ctx context.Context, executorID string) (bool, error) HasActiveTaskSessionsByEnvironment(ctx context.Context, environmentID string) (bool, error) HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) + DeleteEphemeralTasksByAgentProfile(ctx context.Context, agentProfileID string) (int64, error) DeleteTaskSession(ctx context.Context, id string) error GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) GetPrimarySessionIDsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]string, error) diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index ffb8b4821c..1a19a8158c 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -3,7 +3,9 @@ package sqlite import ( "database/sql" + "errors" "fmt" + "strings" "github.com/jmoiron/sqlx" ) @@ -138,6 +140,83 @@ func (r *Repository) runMigrations() error { _, _ = r.db.Exec(`ALTER TABLE task_sessions ADD COLUMN base_commit_sha TEXT DEFAULT ''`) // Add default_config_agent_profile_id column to workspaces (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE workspaces ADD COLUMN default_config_agent_profile_id TEXT DEFAULT ''`) + // Remove FK constraint on workflow_id to allow ephemeral tasks without workflows + if err := r.migrateTasksRemoveWorkflowFK(); err != nil { + return err + } + return nil +} + +// migrateTasksRemoveWorkflowFK removes the foreign key constraint on workflow_id +// to allow ephemeral tasks (quick chat) to have empty workflow_id. +// SQLite requires recreating the table to remove FK constraints. +func (r *Repository) migrateTasksRemoveWorkflowFK() error { + // Check if migration is needed by looking for the FK constraint + var tableSql string + err := r.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'`).Scan(&tableSql) + if errors.Is(err, sql.ErrNoRows) { + return nil // Table doesn't exist yet, skip migration + } + if err != nil { + return fmt.Errorf("query tasks schema: %w", err) + } + if !strings.Contains(tableSql, "FOREIGN KEY (workflow_id)") { + return nil // No FK constraint, migration not needed + } + + // Disable FK enforcement to prevent cascade deletes during table recreation. + // The writer pool has MaxOpenConns(1), so PRAGMA and subsequent operations use the same connection. + // Note: PRAGMA statements cannot run inside a transaction in SQLite. + if _, err := r.db.Exec(`PRAGMA foreign_keys=OFF`); err != nil { + return fmt.Errorf("disable foreign keys: %w", err) + } + defer func() { _, _ = r.db.Exec(`PRAGMA foreign_keys=ON`) }() + + // Wrap migration in a transaction to avoid partial state on failure + tx, err := r.db.Beginx() + if err != nil { + return fmt.Errorf("begin migration transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + // Recreate tasks table without the workflow_id FK constraint + // SQLite requires executing statements one at a time + statements := []string{ + `CREATE TABLE tasks_new ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT '', + workflow_id TEXT NOT NULL DEFAULT '', + workflow_step_id TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL, + description TEXT DEFAULT '', + state TEXT DEFAULT 'TODO', + priority INTEGER DEFAULT 0, + position INTEGER DEFAULT 0, + metadata TEXT DEFAULT '{}', + is_ephemeral INTEGER NOT NULL DEFAULT 0, + archived_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + )`, + `INSERT INTO tasks_new SELECT + id, workspace_id, workflow_id, workflow_step_id, title, description, + state, priority, position, metadata, is_ephemeral, archived_at, created_at, updated_at + FROM tasks`, + `DROP TABLE tasks`, + `ALTER TABLE tasks_new RENAME TO tasks`, + // Use same index names as initCoreIndexes for consistency + `CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id)`, + `CREATE INDEX IF NOT EXISTS idx_tasks_workflow_step_id ON tasks(workflow_step_id)`, + `CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at)`, + } + for _, stmt := range statements { + if _, err := tx.Exec(stmt); err != nil { + return fmt.Errorf("migration failed: %w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration transaction: %w", err) + } return nil } @@ -249,7 +328,7 @@ func (r *Repository) initTaskSchema() error { CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '', - workflow_id TEXT NOT NULL, + workflow_id TEXT NOT NULL DEFAULT '', workflow_step_id TEXT NOT NULL DEFAULT '', title TEXT NOT NULL, description TEXT DEFAULT '', @@ -259,8 +338,7 @@ func (r *Repository) initTaskSchema() error { metadata TEXT DEFAULT '{}', archived_at TIMESTAMP, created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE + updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS task_repositories ( diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 738e6f7800..333a251ccb 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -552,9 +552,13 @@ func (r *Repository) loadWorktreesBatch(ctx context.Context, sessions []*models. func (r *Repository) HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) { var exists int + // Exclude ephemeral tasks (quick chat, config chat) - they shouldn't block profile deletion err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT 1 FROM task_sessions - WHERE agent_profile_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') + SELECT 1 FROM task_sessions ts + JOIN tasks t ON ts.task_id = t.id + WHERE ts.agent_profile_id = ? + AND ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') + AND t.is_ephemeral = 0 LIMIT 1 `), agentProfileID).Scan(&exists) if err == sql.ErrNoRows { @@ -614,6 +618,25 @@ func (r *Repository) HasActiveTaskSessionsByRepository(ctx context.Context, repo return err == nil, err } +// DeleteEphemeralTasksByAgentProfile deletes all ephemeral tasks (and their sessions) +// that are using the specified agent profile. This is used during profile deletion +// to clean up transient quick chat / config chat tasks. +func (r *Repository) DeleteEphemeralTasksByAgentProfile(ctx context.Context, agentProfileID string) (int64, error) { + // Delete tasks that are ephemeral and have sessions using this profile. + // CASCADE will handle session deletion. + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + DELETE FROM tasks + WHERE is_ephemeral = 1 + AND id IN ( + SELECT DISTINCT task_id FROM task_sessions WHERE agent_profile_id = ? + ) + `), agentProfileID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + // scanTaskSessions is a helper to scan multiple agent session rows func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*models.TaskSession, error) { var result []*models.TaskSession diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 6a012bf39c..72f378bdbb 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -25,8 +25,19 @@ type taskStopTarget struct { // Task operations -// CreateTask creates a new task and publishes a task.created event +// CreateTask creates a new task and publishes a task.created event. +// WorkflowID is required for non-ephemeral tasks (kanban tasks). +// Ephemeral tasks (quick chat, config chat) must NOT have a workflow. func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*models.Task, error) { + // Non-ephemeral tasks require a workflow for kanban board placement + if !req.IsEphemeral && req.WorkflowID == "" { + return nil, fmt.Errorf("workflow_id is required for non-ephemeral tasks") + } + // Ephemeral tasks must not have a workflow - they exist outside the kanban board + if req.IsEphemeral && req.WorkflowID != "" { + return nil, fmt.Errorf("workflow_id must be empty for ephemeral tasks") + } + // Auto-resolve start step if not provided workflowStepID := req.WorkflowStepID if workflowStepID == "" && req.WorkflowID != "" && s.startStepResolver != nil { diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx index 6668c1ef43..6c2ba2c47c 100644 --- a/apps/web/app/s/[sessionId]/page.tsx +++ b/apps/web/app/s/[sessionId]/page.tsx @@ -20,6 +20,7 @@ import type { Task, TaskSession, UserSettingsResponse, + WorkflowSnapshot, } from "@/lib/types/http"; import type { Terminal } from "@/hooks/domains/session/use-terminals"; import { snapshotToState, taskToState } from "@/lib/ssr/mapper"; @@ -191,7 +192,10 @@ async function fetchSessionData(paramSessionId: string): Promise["push"]; @@ -167,6 +167,9 @@ export function GlobalCommands() { startNewConfigChat, ]); + const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); + const quickChatShortcut = getShortcut("QUICK_CHAT", keyboardShortcuts); + const quickChatCommand: CommandItem = useMemo( () => ({ id: "quick-chat", @@ -174,10 +177,10 @@ export function GlobalCommands() { group: "Actions", icon: , keywords: ["quick", "chat", "ai", "agent"], - shortcut: SHORTCUTS.QUICK_CHAT, + shortcut: quickChatShortcut, action: handleOpenQuickChat, }), - [handleOpenQuickChat], + [handleOpenQuickChat, quickChatShortcut], ); const configChatCommand: CommandItem = useMemo( @@ -203,7 +206,7 @@ export function GlobalCommands() { ); useRegisterCommands(commands); - useKeyboardShortcut(SHORTCUTS.QUICK_CHAT, handleOpenQuickChat); + useKeyboardShortcut(quickChatShortcut, handleOpenQuickChat); return null; } diff --git a/apps/web/components/quick-chat/quick-chat-content.tsx b/apps/web/components/quick-chat/quick-chat-content.tsx index ec06af5399..2824ba5bca 100644 --- a/apps/web/components/quick-chat/quick-chat-content.tsx +++ b/apps/web/components/quick-chat/quick-chat-content.tsx @@ -54,6 +54,15 @@ export const QuickChatContent = memo(function QuickChatContent({ } = panelState; const { handleCancelTurn } = useChatPanelHandlers(resolvedSessionId, cancelQueue, chatInputRef); + // Auto-focus the chat input when the component mounts + useEffect(() => { + // Small delay to ensure the input is rendered + const timer = setTimeout(() => { + chatInputRef.current?.focusInput(); + }, 50); + return () => clearTimeout(timer); + }, []); + // Auto-send the initial prompt once the session is ready (has taskId). // This is used by config chat which creates the session first, then sends // the user's prompt through normal WS message flow. @@ -104,6 +113,7 @@ export const QuickChatContent = memo(function QuickChatContent({ isSending={isSending} hideSessionsDropdown={true} minimalToolbar={minimalToolbar} + hidePlanMode={true} placeholderOverride={placeholderOverride} /> diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 497fc7d2cf..49913bd23d 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -326,6 +326,8 @@ type ChatInputAreaProps = { isSending: boolean; hideSessionsDropdown?: boolean; minimalToolbar?: boolean; + /** Hide the plan mode toggle button (for ephemeral/quick chat sessions) */ + hidePlanMode?: boolean; placeholderOverride?: string; }; @@ -341,6 +343,7 @@ export function ChatInputArea({ isSending, hideSessionsDropdown, minimalToolbar, + hidePlanMode, placeholderOverride, }: ChatInputAreaProps) { const { @@ -423,6 +426,7 @@ export function ChatInputArea({ onImplementPlan={handleImplementPlan} hideSessionsDropdown={hideSessionsDropdown} minimalToolbar={minimalToolbar} + hidePlanMode={hidePlanMode} /> ); diff --git a/apps/web/components/task/chat/chat-input-body.tsx b/apps/web/components/task/chat/chat-input-body.tsx index fb1fef3178..1b0d3848a5 100644 --- a/apps/web/components/task/chat/chat-input-body.tsx +++ b/apps/web/components/task/chat/chat-input-body.tsx @@ -35,6 +35,7 @@ export type ChatInputEditorAreaProps = { showRequestChangesTooltip: boolean; hideSessionsDropdown?: boolean; minimalToolbar?: boolean; + hidePlanMode?: boolean; isAgentBusy: boolean; onPlanModeChange: (enabled: boolean) => void; taskTitle?: string; @@ -119,7 +120,7 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { p; const { isSending, onCancel, contextCount, contextPopoverOpen, setContextPopoverOpen } = p; const { contextFiles, onImplementPlan, onEnhancePrompt, isEnhancingPrompt } = p; - const { isUtilityConfigured, hideSessionsDropdown, minimalToolbar } = p; + const { isUtilityConfigured, hideSessionsDropdown, minimalToolbar, hidePlanMode } = p; // Block submit while enhancing prompt, but keep editor editable for programmatic updates const wrappedSubmit = isEnhancingPrompt ? () => {} : handleSubmitWithReset; const handleAttachFiles = useCallback(() => fileInputRef.current?.click(), [fileInputRef]); @@ -179,6 +180,7 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { onAttachFiles={handleAttachFiles} hideSessionsDropdown={hideSessionsDropdown} minimalToolbar={minimalToolbar} + hidePlanMode={hidePlanMode} /> ); diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx index 1ca10ec74c..4037a6752d 100644 --- a/apps/web/components/task/chat/chat-input-container.tsx +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -75,6 +75,8 @@ type ChatInputContainerProps = { onImplementPlan?: () => void; hideSessionsDropdown?: boolean; minimalToolbar?: boolean; + /** Hide the plan mode toggle button (for ephemeral/quick chat sessions) */ + hidePlanMode?: boolean; }; function FailedSessionBanner({ @@ -186,6 +188,7 @@ function buildEditorAreaProps( isUtilityConfigured: extras.isUtilityConfigured, hideSessionsDropdown: p.hideSessionsDropdown, minimalToolbar: p.minimalToolbar, + hidePlanMode: p.hidePlanMode, }; } diff --git a/apps/web/components/task/chat/chat-input-toolbar.tsx b/apps/web/components/task/chat/chat-input-toolbar.tsx index ce45c052e6..b5e19f11c6 100644 --- a/apps/web/components/task/chat/chat-input-toolbar.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar.tsx @@ -72,6 +72,8 @@ export type ChatInputToolbarProps = { hideSessionsDropdown?: boolean; /** When true, only render the submit/cancel button — no other controls */ minimalToolbar?: boolean; + /** Hide the plan mode toggle button (for ephemeral/quick chat sessions) */ + hidePlanMode?: boolean; }; type SubmitButtonProps = { @@ -446,6 +448,7 @@ const toolbarDefaults = { contextFiles: [] as ContextFile[], isEnhancingPrompt: false, isUtilityConfigured: false, + hidePlanMode: false, }; export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInputToolbarProps) { @@ -468,11 +471,13 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp return (
- + {!props.hidePlanMode && ( + + )} diff --git a/apps/web/components/task/quick-chat-button.tsx b/apps/web/components/task/quick-chat-button.tsx index 10ff50d4a8..fff34d880e 100644 --- a/apps/web/components/task/quick-chat-button.tsx +++ b/apps/web/components/task/quick-chat-button.tsx @@ -3,17 +3,20 @@ import { IconMessageCircle } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip"; -import { SHORTCUTS } from "@/lib/keyboard/constants"; +import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; +import { useAppStore } from "@/components/state-provider"; /** Quick Chat button that opens the quick chat modal */ export function QuickChatButton({ workspaceId }: { workspaceId?: string | null }) { const handleOpenQuickChat = useQuickChatLauncher(workspaceId); + const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); + const quickChatShortcut = getShortcut("QUICK_CHAT", keyboardShortcuts); if (!workspaceId) return null; return ( - +