feat: make quick chats independent of workflows (#434)

This commit is contained in:
João Salavisa
2026-03-15 10:50:50 +00:00
committed by GitHub
parent 8c3f449bdd
commit 63efc85dd1
22 changed files with 254 additions and 64 deletions
@@ -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 {
+5 -1
View File
@@ -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" }),
+7 -4
View File
@@ -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"
+3 -1
View File
@@ -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),
};
}
+12
View File
@@ -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) => {