feat: make quick chats independent of workflows (#434)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -57,6 +57,7 @@ func TestApplyWorkflowAndPlanMode_KeepsWorkflowPromptVisibleWhenStepEnablesPlanM
|
||||
"session-1",
|
||||
"step-1",
|
||||
false,
|
||||
false, // isEphemeral
|
||||
)
|
||||
|
||||
if !planModeActive {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<FetchedSessionD
|
||||
terminalsResponse,
|
||||
messagesResponse,
|
||||
] = await Promise.all([
|
||||
fetchWorkflowSnapshot(task.workflow_id, { cache: "no-store" }),
|
||||
// Ephemeral tasks (quick chat) have empty workflow_id - skip snapshot fetch
|
||||
task.workflow_id
|
||||
? fetchWorkflowSnapshot(task.workflow_id, { cache: "no-store" })
|
||||
: Promise.resolve({ steps: [], tasks: [] } as unknown as WorkflowSnapshot),
|
||||
listAgents({ cache: "no-store" }),
|
||||
listRepositories(task.workspace_id, { includeScripts: true }, { cache: "no-store" }),
|
||||
listTaskSessions(session.task_id, { cache: "no-store" }),
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { useRegisterCommands } from "@/hooks/use-register-commands";
|
||||
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { SHORTCUTS } from "@/lib/keyboard/constants";
|
||||
import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
|
||||
import type { CommandItem } from "@/lib/commands/types";
|
||||
|
||||
type PushFn = ReturnType<typeof useRouter>["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: <IconMessageCircle className="size-3.5" />,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-1 px-1 pt-0 pb-0.5 border-t border-border">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<PlanToggleButton
|
||||
planModeEnabled={props.planModeEnabled}
|
||||
planModeAvailable={props.planModeAvailable}
|
||||
onPlanModeChange={props.onPlanModeChange}
|
||||
/>
|
||||
{!props.hidePlanMode && (
|
||||
<PlanToggleButton
|
||||
planModeEnabled={props.planModeEnabled}
|
||||
planModeAvailable={props.planModeAvailable}
|
||||
onPlanModeChange={props.onPlanModeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<McpIndicator mcpServers={props.mcpServers} />
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<KeyboardShortcutTooltip shortcut={SHORTCUTS.QUICK_CHAT} description="Quick Chat">
|
||||
<KeyboardShortcutTooltip shortcut={quickChatShortcut} description="Quick Chat">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SHORTCUTS, type KeyboardShortcut } from "./constants";
|
||||
|
||||
export type ConfigurableShortcutId = "SEARCH" | "FILE_SEARCH";
|
||||
export type ConfigurableShortcutId = "SEARCH" | "FILE_SEARCH" | "QUICK_CHAT";
|
||||
|
||||
export type StoredShortcutOverrides = Record<
|
||||
string,
|
||||
@@ -13,6 +13,7 @@ export const CONFIGURABLE_SHORTCUTS: Record<
|
||||
> = {
|
||||
SEARCH: { label: "Command Panel", default: SHORTCUTS.SEARCH },
|
||||
FILE_SEARCH: { label: "File Search", default: SHORTCUTS.FILE_SEARCH },
|
||||
QUICK_CHAT: { label: "Quick Chat", default: SHORTCUTS.QUICK_CHAT },
|
||||
};
|
||||
|
||||
export function getShortcut(
|
||||
@@ -30,5 +31,6 @@ export function resolveAllShortcuts(
|
||||
return {
|
||||
SEARCH: getShortcut("SEARCH", overrides),
|
||||
FILE_SEARCH: getShortcut("FILE_SEARCH", overrides),
|
||||
QUICK_CHAT: getShortcut("QUICK_CHAT", overrides),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,18 @@ import type { WorkflowSnapshot, Message, Task } from "@/lib/types/http";
|
||||
type KanbanTask = KanbanState["tasks"][number];
|
||||
|
||||
export function snapshotToState(snapshot: WorkflowSnapshot): Partial<AppState> {
|
||||
// Handle empty snapshot (ephemeral tasks have no workflow)
|
||||
if (!snapshot.workflow) {
|
||||
return {
|
||||
kanban: {
|
||||
workflowId: "",
|
||||
isLoading: false,
|
||||
steps: [],
|
||||
tasks: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const tasks = snapshot.tasks
|
||||
.filter((task) => !task.is_ephemeral) // Filter out ephemeral tasks (e.g., quick chat)
|
||||
.map((task) => {
|
||||
|
||||
Reference in New Issue
Block a user