diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index 1bcce1dc22..c8fc0f468c 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -264,6 +264,7 @@ func (a *lifecycleAdapter) ResolveAgentProfile(ctx context.Context, profileID st AutoApprove: info.AutoApprove, DangerouslySkipPermissions: info.DangerouslySkipPermissions, CLIPassthrough: info.CLIPassthrough, + SupportsMCP: info.SupportsMCP, }, nil } diff --git a/apps/backend/internal/agent/lifecycle/manager_execution.go b/apps/backend/internal/agent/lifecycle/manager_execution.go index 0e0aaa40c2..431b3b99d9 100644 --- a/apps/backend/internal/agent/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/lifecycle/manager_execution.go @@ -140,6 +140,15 @@ func (m *Manager) createExecutionFromSessionInfo(ctx context.Context, sessionID return nil, err } + // If agent ID not in workspace info (snapshot missing/empty), resolve from profile + if info.AgentID == "" && info.AgentProfileID != "" && m.profileResolver != nil { + profileInfo, err := m.profileResolver.ResolveProfile(ctx, info.AgentProfileID) + if err != nil { + return nil, fmt.Errorf("resolve agent for session %s: %w", sessionID, err) + } + info.AgentID = profileInfo.AgentName + } + // Create the execution m.logger.Info("creating execution for passthrough session", zap.String("task_id", info.TaskID), diff --git a/apps/backend/internal/agent/lifecycle/profile_resolver.go b/apps/backend/internal/agent/lifecycle/profile_resolver.go index 4156bf236d..8740713370 100644 --- a/apps/backend/internal/agent/lifecycle/profile_resolver.go +++ b/apps/backend/internal/agent/lifecycle/profile_resolver.go @@ -48,6 +48,7 @@ func (r *StoreProfileResolver) ResolveProfile(ctx context.Context, profileID str AllowIndexing: profile.AllowIndexing, CLIPassthrough: profile.CLIPassthrough, NativeSessionResume: nativeSessionResume, + SupportsMCP: agent.SupportsMCP, }, nil } diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index 0c568f5319..93ffc693a2 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -203,6 +203,7 @@ type AgentProfileInfo struct { AllowIndexing bool CLIPassthrough bool NativeSessionResume bool // Agent supports ACP session/load for resume + SupportsMCP bool } // ProfileResolver resolves agent profile IDs to profile information diff --git a/apps/backend/internal/github/client_helpers.go b/apps/backend/internal/github/client_helpers.go index 8f894d8d7d..72019defb3 100644 --- a/apps/backend/internal/github/client_helpers.go +++ b/apps/backend/internal/github/client_helpers.go @@ -39,7 +39,7 @@ func getPRFeedback(ctx context.Context, c Client, owner, repo string, number int if err != nil { return nil, err } - checks, err := c.ListCheckRuns(ctx, owner, repo, pr.HeadBranch) + checks, err := c.ListCheckRuns(ctx, owner, repo, pr.HeadSHA) if err != nil { return nil, err } diff --git a/apps/backend/internal/github/constants.go b/apps/backend/internal/github/constants.go index fe17c41e86..84a98361b7 100644 --- a/apps/backend/internal/github/constants.go +++ b/apps/backend/internal/github/constants.go @@ -1,5 +1,11 @@ package github +// Normalized PR states (lowercase, used after conversion from GitHub API). +const ( + prStateMerged = "merged" + prStateClosed = "closed" +) + // PR review states from the GitHub API. const ( reviewStateApproved = "APPROVED" diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index 79770f9842..c8363ef18a 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -54,6 +54,7 @@ type ghPR struct { URL string `json:"url"` State string `json:"state"` HeadRefName string `json:"headRefName"` + HeadRefOid string `json:"headRefOid"` BaseRefName string `json:"baseRefName"` IsDraft bool `json:"isDraft"` Mergeable string `json:"mergeable"` @@ -71,7 +72,7 @@ type ghPR struct { func (c *GHClient) GetPR(ctx context.Context, owner, repo string, number int) (*PR, error) { out, err := c.run(ctx, "pr", "view", fmt.Sprintf("%d", number), "--repo", fmt.Sprintf("%s/%s", owner, repo), - "--json", "number,title,url,state,headRefName,baseRefName,author,isDraft,mergeable,additions,deletions,createdAt,updatedAt,mergedAt,closedAt") + "--json", "number,title,url,state,headRefName,headRefOid,baseRefName,author,isDraft,mergeable,additions,deletions,createdAt,updatedAt,mergedAt,closedAt") if err != nil { return nil, fmt.Errorf("get PR #%d: %w", number, err) } @@ -87,7 +88,7 @@ func (c *GHClient) FindPRByBranch(ctx context.Context, owner, repo, branch strin "--repo", fmt.Sprintf("%s/%s", owner, repo), "--head", branch, "--state", "open", - "--json", "number,title,url,state,headRefName,baseRefName,author,isDraft,mergeable,additions,deletions,createdAt,updatedAt", + "--json", "number,title,url,state,headRefName,headRefOid,baseRefName,author,isDraft,mergeable,additions,deletions,createdAt,updatedAt", "--limit", "1") if err != nil { return nil, fmt.Errorf("find PR by branch %q: %w", branch, err) @@ -107,7 +108,7 @@ func (c *GHClient) ListAuthoredPRs(ctx context.Context, owner, repo string) ([]* "--repo", fmt.Sprintf("%s/%s", owner, repo), "--author", "@me", "--state", "open", - "--json", "number,title,url,state,headRefName,baseRefName,author,isDraft,mergeable,additions,deletions,createdAt,updatedAt") + "--json", "number,title,url,state,headRefName,headRefOid,baseRefName,author,isDraft,mergeable,additions,deletions,createdAt,updatedAt") if err != nil { return nil, fmt.Errorf("list authored PRs: %w", err) } @@ -361,7 +362,7 @@ func (c *GHClient) parseSearchResults(data string) ([]*PR, error) { func convertGHPR(raw *ghPR, owner, repo string) *PR { state := strings.ToLower(raw.State) if raw.MergedAt != "" { - state = "merged" + state = prStateMerged } pr := &PR{ Number: raw.Number, @@ -370,6 +371,7 @@ func convertGHPR(raw *ghPR, owner, repo string) *PR { HTMLURL: raw.URL, State: state, HeadBranch: raw.HeadRefName, + HeadSHA: raw.HeadRefOid, BaseBranch: raw.BaseRefName, AuthorLogin: raw.Author.Login, RepoOwner: owner, diff --git a/apps/backend/internal/github/gh_client_test.go b/apps/backend/internal/github/gh_client_test.go index f2d3bf7add..7db24c3211 100644 --- a/apps/backend/internal/github/gh_client_test.go +++ b/apps/backend/internal/github/gh_client_test.go @@ -93,6 +93,7 @@ func TestConvertGHPR(t *testing.T) { URL: "https://github.com/owner/repo/pull/42", State: "OPEN", HeadRefName: "feature-branch", + HeadRefOid: "abc123def456", BaseRefName: "main", IsDraft: true, Mergeable: "MERGEABLE", @@ -116,6 +117,9 @@ func TestConvertGHPR(t *testing.T) { if pr.HeadBranch != "feature-branch" { t.Errorf("head_branch = %q, want feature-branch", pr.HeadBranch) } + if pr.HeadSHA != "abc123def456" { + t.Errorf("head_sha = %q, want abc123def456", pr.HeadSHA) + } if !pr.Draft { t.Error("expected draft = true") } @@ -142,7 +146,7 @@ func TestConvertGHPR_Merged(t *testing.T) { pr := convertGHPR(raw, "owner", "repo") - if pr.State != "merged" { + if pr.State != prStateMerged { t.Errorf("state = %q, want merged", pr.State) } if pr.MergedAt == nil { diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index 2eef0f8aae..55669ac1d1 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -12,6 +12,7 @@ type PR struct { HTMLURL string `json:"html_url"` State string `json:"state"` // open, closed, merged HeadBranch string `json:"head_branch"` + HeadSHA string `json:"head_sha"` BaseBranch string `json:"base_branch"` AuthorLogin string `json:"author_login"` RepoOwner string `json:"repo_owner"` diff --git a/apps/backend/internal/github/pat_client.go b/apps/backend/internal/github/pat_client.go index e9dee3bbcf..8a6d680fd9 100644 --- a/apps/backend/internal/github/pat_client.go +++ b/apps/backend/internal/github/pat_client.go @@ -257,6 +257,7 @@ type patPR struct { } `json:"user"` Head struct { Ref string `json:"ref"` + SHA string `json:"sha"` } `json:"head"` Base struct { Ref string `json:"ref"` @@ -280,7 +281,7 @@ type patSearchItem struct { func convertPatPR(raw *patPR, owner, repo string) *PR { state := strings.ToLower(raw.State) if raw.MergedAt != nil && *raw.MergedAt != "" { - state = "merged" + state = prStateMerged } mergeable := false if raw.Mergeable != nil { @@ -292,6 +293,7 @@ func convertPatPR(raw *patPR, owner, repo string) *PR { HTMLURL: raw.HTMLURL, State: state, HeadBranch: raw.Head.Ref, + HeadSHA: raw.Head.SHA, BaseBranch: raw.Base.Ref, AuthorLogin: raw.User.Login, RepoOwner: owner, diff --git a/apps/backend/internal/github/pat_client_test.go b/apps/backend/internal/github/pat_client_test.go index 8ea7417eee..89fab5a4e3 100644 --- a/apps/backend/internal/github/pat_client_test.go +++ b/apps/backend/internal/github/pat_client_test.go @@ -21,7 +21,8 @@ func TestConvertPatPR(t *testing.T) { }{Login: "bob"}, Head: struct { Ref string `json:"ref"` - }{Ref: "feature-y"}, + SHA string `json:"sha"` + }{Ref: "feature-y", SHA: "deadbeef1234"}, Base: struct { Ref string `json:"ref"` }{Ref: "main"}, @@ -41,6 +42,9 @@ func TestConvertPatPR(t *testing.T) { if pr.HeadBranch != "feature-y" { t.Errorf("head = %q, want feature-y", pr.HeadBranch) } + if pr.HeadSHA != "deadbeef1234" { + t.Errorf("head_sha = %q, want deadbeef1234", pr.HeadSHA) + } if pr.Mergeable { t.Error("expected mergeable = false when nil") } @@ -60,6 +64,7 @@ func TestConvertPatPR_Merged(t *testing.T) { }{Login: "alice"}, Head: struct { Ref string `json:"ref"` + SHA string `json:"sha"` }{Ref: "fix"}, Base: struct { Ref string `json:"ref"` @@ -68,7 +73,7 @@ func TestConvertPatPR_Merged(t *testing.T) { pr := convertPatPR(raw, "org", "repo") - if pr.State != "merged" { + if pr.State != prStateMerged { t.Errorf("state = %q, want merged", pr.State) } if pr.MergedAt == nil { @@ -87,6 +92,7 @@ func TestConvertPatPR_Mergeable(t *testing.T) { }{Login: "alice"}, Head: struct { Ref string `json:"ref"` + SHA string `json:"sha"` }{Ref: "b"}, Base: struct { Ref string `json:"ref"` diff --git a/apps/backend/internal/github/poller.go b/apps/backend/internal/github/poller.go index 207d4ced9b..0f2765fe65 100644 --- a/apps/backend/internal/github/poller.go +++ b/apps/backend/internal/github/poller.go @@ -110,7 +110,25 @@ func (p *Poller) checkSinglePRWatch(ctx context.Context, watch *PRWatch) { zap.String("id", watch.ID), zap.Error(err)) return } - if !hasNew || feedback == nil { + if feedback == nil { + return + } + + // Auto-cleanup: remove watch when PR is merged or closed. + if feedback.PR != nil && (feedback.PR.State == prStateMerged || feedback.PR.State == prStateClosed) { + if delErr := p.service.DeletePRWatch(ctx, watch.ID); delErr != nil { + p.logger.Error("failed to delete completed PR watch", + zap.String("id", watch.ID), zap.Error(delErr)) + } else { + p.logger.Info("removed PR watch for completed PR", + zap.String("id", watch.ID), + zap.String("state", feedback.PR.State), + zap.Int("pr_number", watch.PRNumber)) + } + return + } + + if !hasNew { return } diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 7ce1daea55..9492b339e4 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -125,7 +125,9 @@ func (m *mockAgentManager) IsAgentRunningForSession(_ context.Context, _ string) return m.isAgentRunning } func (m *mockAgentManager) ResolveAgentProfile(_ context.Context, _ string) (*executor.AgentProfileInfo, error) { - return nil, nil + return &executor.AgentProfileInfo{ + SupportsMCP: true, + }, nil } func (m *mockAgentManager) RestartAgentProcess(_ context.Context, agentExecutionID string) error { m.restartProcessCalls = append(m.restartProcessCalls, agentExecutionID) diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index bc6aeb1425..f93f5a944b 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -293,6 +293,9 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s func (s *Service) processOnEnter(ctx context.Context, taskID, sessionID string, step *wfmodels.WorkflowStep, taskDescription string) { isPassthrough := s.agentManager.IsPassthroughSession(ctx, sessionID) + // Resolve whether the agent supports MCP (needed for plan mode which uses MCP tools). + agentSupportsMCP := s.resolveSessionMCPSupport(ctx, sessionID) + // Check if this step enables plan mode hasPlanMode := false for _, action := range step.Events.OnEnter { @@ -302,6 +305,15 @@ func (s *Service) processOnEnter(ctx context.Context, taskID, sessionID string, } } + // Plan mode requires MCP support. If the agent doesn't support MCP, treat as if + // the step doesn't enable plan mode so we don't send plan-mode prompts the agent can't act on. + if hasPlanMode && !agentSupportsMCP { + s.logger.Warn("skipping plan mode for step: agent does not support MCP", + zap.String("session_id", sessionID), + zap.String("step_id", step.ID)) + hasPlanMode = false + } + // Skip plan mode management for passthrough sessions — the CLI manages its own state. // For ACP sessions, auto-disable plan mode when entering a step that doesn't explicitly enable it. if !isPassthrough { @@ -330,8 +342,9 @@ func (s *Service) processOnEnter(ctx context.Context, taskID, sessionID string, for _, action := range step.Events.OnEnter { switch action.Type { case wfmodels.OnEnterEnablePlanMode: - // Skip plan mode for passthrough — CLI manages its own state - if !isPassthrough { + // Skip plan mode for passthrough — CLI manages its own state. + // Also skip if agent doesn't support MCP (hasPlanMode is already false above). + if !isPassthrough && hasPlanMode { s.setSessionPlanMode(ctx, sessionID, true) } case wfmodels.OnEnterAutoStartAgent: @@ -344,7 +357,7 @@ func (s *Service) processOnEnter(ctx context.Context, taskID, sessionID string, if hasAutoStart && !isPassthrough { // Build prompt from step configuration effectivePrompt := s.buildWorkflowPrompt(taskDescription, step, taskID) - planMode := step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode) + planMode := hasPlanMode // Run auto-start inline so queue state is visible before handleAgentReady // checks for queued messages. @@ -494,6 +507,31 @@ func (s *Service) resetAgentContext(ctx context.Context, taskID, sessionID, step return true } +// resolveSessionMCPSupport checks if the agent for a session supports MCP. +// Returns true by default when the profile cannot be resolved (e.g. no profile ID set) +// so that plan mode is not blocked unnecessarily. +func (s *Service) resolveSessionMCPSupport(ctx context.Context, sessionID string) bool { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + s.logger.Warn("failed to get session for MCP check", + zap.String("session_id", sessionID), + zap.Error(err)) + return true + } + if session.AgentProfileID == "" { + return true + } + profileInfo, err := s.agentManager.ResolveAgentProfile(ctx, session.AgentProfileID) + if err != nil { + s.logger.Warn("failed to resolve agent profile for MCP check", + zap.String("session_id", sessionID), + zap.String("profile_id", session.AgentProfileID), + zap.Error(err)) + return true + } + return profileInfo.SupportsMCP +} + // processOnExit processes the on_exit events for a step when leaving it. // This is called before transitioning to the next step. Only side-effect actions // are supported (no transitions — those are decided by on_turn_complete). diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index f905d9394d..8836a236a9 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -140,6 +140,7 @@ type AgentProfileInfo struct { DangerouslySkipPermissions bool CLIPassthrough bool NativeSessionResume bool // Agent supports ACP session/load for resume + SupportsMCP bool } // LaunchAgentRequest contains parameters for launching an agent diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index ff0ecb8857..c74208df1e 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -101,6 +101,7 @@ type wsStartTaskRequest struct { Priority int `json:"priority,omitempty"` Prompt string `json:"prompt,omitempty"` WorkflowStepID string `json:"workflow_step_id,omitempty"` + PlanMode bool `json:"plan_mode,omitempty"` } func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -157,7 +158,7 @@ func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Messag } // Otherwise, create a new session - execution, err := h.service.StartTask(ctx, req.TaskID, req.AgentProfileID, req.ExecutorID, req.ExecutorProfileID, req.Priority, req.Prompt, req.WorkflowStepID, false) + execution, err := h.service.StartTask(ctx, req.TaskID, req.AgentProfileID, req.ExecutorID, req.ExecutorProfileID, req.Priority, req.Prompt, req.WorkflowStepID, req.PlanMode) if err != nil { h.logger.Error("failed to start task", zap.String("task_id", req.TaskID), zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to start task: "+err.Error(), nil) diff --git a/apps/backend/internal/sysprompt/sysprompt.go b/apps/backend/internal/sysprompt/sysprompt.go index 325a5164da..87b24f131a 100644 --- a/apps/backend/internal/sysprompt/sysprompt.go +++ b/apps/backend/internal/sysprompt/sysprompt.go @@ -25,7 +25,7 @@ var systemTagRegex = regexp.MustCompile(`[\s\S]*? // StripSystemContent removes all ... blocks from text. // This is used to hide system-injected content from the frontend UI. func StripSystemContent(text string) string { - return systemTagRegex.ReplaceAllString(text, "") + return strings.TrimSpace(systemTagRegex.ReplaceAllString(text, "")) } // Wrap wraps content in tags to mark it as system-injected. @@ -33,6 +33,11 @@ func Wrap(content string) string { return TagStart + content + TagEnd } +// HasSystemContent checks whether the text contains any tags. +func HasSystemContent(text string) bool { + return systemTagRegex.MatchString(text) +} + // PlanMode is the system prompt prepended when plan mode is enabled. // It instructs agents to collaborate on the plan without implementing changes. // Used for both initial plan creation and follow-up plan refinement messages. diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index fe8fb61911..7bd4d87122 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -1,6 +1,7 @@ package models import ( + "maps" "time" "github.com/kandev/kandev/internal/sysprompt" @@ -136,6 +137,17 @@ func (m *Message) ToAPI() *v1.Message { if messageType == "" { messageType = string(MessageTypeMessage) } + hasHidden := sysprompt.HasSystemContent(m.Content) + meta := m.Metadata + if hasHidden { + if meta == nil { + meta = make(map[string]interface{}) + } else { + // Copy to avoid mutating original + meta = copyMetadata(meta) + } + meta["has_hidden_prompts"] = true + } result := &v1.Message{ ID: m.ID, TaskSessionID: m.TaskSessionID, @@ -145,13 +157,23 @@ func (m *Message) ToAPI() *v1.Message { AuthorID: m.AuthorID, Content: sysprompt.StripSystemContent(m.Content), Type: messageType, - Metadata: m.Metadata, + Metadata: meta, RequestsInput: m.RequestsInput, CreatedAt: m.CreatedAt, } + if hasHidden { + result.RawContent = m.Content + } return result } +// copyMetadata returns a shallow copy of a metadata map. +func copyMetadata(m map[string]any) map[string]any { + cp := make(map[string]any, len(m)) + maps.Copy(cp, m) + return cp +} + // Turn represents a single prompt/response cycle within a task session. // A turn starts when a user sends a prompt and ends when the agent completes, // cancels, or errors. diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 3b17db7398..3970c29dd5 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -197,6 +197,7 @@ func (s *Service) publishMessageEvent(ctx context.Context, eventType string, mes messageType = "message" } + hasHidden := sysprompt.HasSystemContent(message.Content) data := map[string]interface{}{ "message_id": message.ID, "session_id": message.TaskSessionID, @@ -210,8 +211,25 @@ func (s *Service) publishMessageEvent(ctx context.Context, eventType string, mes "created_at": message.CreatedAt.Format(time.RFC3339), } - if message.Metadata != nil { - data["metadata"] = message.Metadata + if hasHidden { + data["raw_content"] = message.Content + } + + meta := message.Metadata + if hasHidden { + if meta == nil { + meta = make(map[string]interface{}) + } else { + cp := make(map[string]interface{}, len(meta)) + for k, v := range meta { + cp[k] = v + } + meta = cp + } + meta["has_hidden_prompts"] = true + } + if meta != nil { + data["metadata"] = meta } event := bus.NewEvent(eventType, "task-service", data) diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index a46ab41c46..e702146bc5 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -215,10 +215,13 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio } } - // Get agent ID from profile snapshot + // Get agent name (registry slug) from profile snapshot. + // Prefer "agent_name" (the slug used as registry key) over "agent_id" (the database UUID). var agentID string if session.AgentProfileSnapshot != nil { - if id, ok := session.AgentProfileSnapshot["agent_id"].(string); ok { + if name, ok := session.AgentProfileSnapshot["agent_name"].(string); ok { + agentID = name + } else if id, ok := session.AgentProfileSnapshot["agent_id"].(string); ok { agentID = id } } diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index 31e3cd1245..fd9f9de101 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -142,6 +142,7 @@ type Message struct { Type string `json:"type,omitempty"` AuthorID string `json:"author_id,omitempty"` Content string `json:"content"` + RawContent string `json:"raw_content,omitempty"` RequestsInput bool `json:"requests_input"` // True if agent is requesting user input Metadata map[string]interface{} `json:"metadata,omitempty"` CreatedAt time.Time `json:"created_at"` diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 4247c39c8c..1ea511f32c 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -422,6 +422,9 @@ importers: shadcn: specifier: ^3.6.3 version: 3.6.3(@types/node@20.19.28)(hono@4.11.3)(typescript@5.9.3) + shiki: + specifier: ^3.22.0 + version: 3.22.0 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index ca9b7fa64b..d828ca48e6 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -198,10 +198,6 @@ overflow-wrap: anywhere; } - .kanban-task-pulse { - animation: kanban-task-pulse 5s ease-in-out infinite; - } - .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; @@ -312,16 +308,6 @@ animation-delay: 0.2s; } -@keyframes kanban-task-pulse { - 0%, - 100% { - box-shadow: 0 0 0 0 rgba(79, 70, 229, 0.12); - } - 50% { - box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.22); - } -} - @keyframes border-flash-glow { 0%, 100% { @@ -378,6 +364,26 @@ animation: chat-input-running 3s ease-in-out infinite; } +@keyframes chat-input-running-plan { + 0%, + 100% { + border-color: color-mix(in oklab, oklch(0.6 0.05 260) 40%, var(--border)); + box-shadow: + 0 0 6px 1px color-mix(in oklab, oklch(0.6 0.05 260) 12%, transparent), + 0 0 15px 3px color-mix(in oklab, oklch(0.6 0.05 260) 6%, transparent); + } + 50% { + border-color: color-mix(in oklab, oklch(0.6 0.05 260) 70%, var(--border)); + box-shadow: + 0 0 20px 4px color-mix(in oklab, oklch(0.6 0.05 260) 20%, transparent), + 0 0 50px 8px color-mix(in oklab, oklch(0.6 0.05 260) 10%, transparent); + } +} + +.chat-input-running-plan { + animation: chat-input-running-plan 3s ease-in-out infinite; +} + @layer components { .border-flash-animation { position: relative; @@ -1132,6 +1138,36 @@ pointer-events: none; } +.chat-message-list { + font-size: 13px; + font-family: + -apple-system, "system-ui", "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji"; +} + +.chat-message-list .prose { + font-size: inherit; + font-family: inherit; +} + +.chat-message-list .prose :where(p, li, ul, ol, blockquote, h1, h2, h3, h4, h5, h6, td, th) { + font-family: inherit; +} + +/* Shiki code block styling */ +.shiki-code-block pre.shiki { + margin: 0; + padding: 8px 12px; + overflow-x: auto; + font-size: 12px; + line-height: 1.5; + background-color: var(--background) !important; +} + +.shiki-code-block pre.shiki code { + font-family: var(--font-geist-mono, ui-monospace, monospace); +} + .debug { border: 1px solid red !important; } diff --git a/apps/web/components/diff-stat.tsx b/apps/web/components/diff-stat.tsx index b6315ccaed..6a3899cfc5 100644 --- a/apps/web/components/diff-stat.tsx +++ b/apps/web/components/diff-stat.tsx @@ -16,8 +16,8 @@ type LineStatProps = { }; const commitTone = { - ahead: "text-emerald-200 bg-emerald-600/40", - behind: "text-yellow-200 bg-yellow-600/40", + ahead: "text-emerald-500 border-emerald-500/40", + behind: "text-yellow-500 border-yellow-500/40", }; const lineTone = { @@ -28,12 +28,8 @@ const lineTone = { export function CommitStatBadge({ label, tone, className }: CommitStatBadgeProps) { return ( {label} diff --git a/apps/web/components/editors/codemirror/codemirror-code-block.tsx b/apps/web/components/editors/codemirror/codemirror-code-block.tsx index 26ed9271dc..0ee9b4e37d 100644 --- a/apps/web/components/editors/codemirror/codemirror-code-block.tsx +++ b/apps/web/components/editors/codemirror/codemirror-code-block.tsx @@ -45,7 +45,7 @@ export function CodeMirrorCodeBlock({ children, className }: CodeBlockProps) { + + {html ? ( +
+ ) : ( +
+          {code}
+        
+ )} +
+ ); +} diff --git a/apps/web/components/editors/shiki/shiki-highlighter.ts b/apps/web/components/editors/shiki/shiki-highlighter.ts new file mode 100644 index 0000000000..c625280ff1 --- /dev/null +++ b/apps/web/components/editors/shiki/shiki-highlighter.ts @@ -0,0 +1,36 @@ +import { createHighlighter, type Highlighter } from "shiki"; +let highlighterPromise: Promise | null = null; +const loadedLanguages = new Set(); + +const DARK_THEME = "dark-plus"; +const LIGHT_THEME = "light-plus"; + +function getHighlighter(): Promise { + if (!highlighterPromise) { + highlighterPromise = createHighlighter({ + themes: [DARK_THEME, LIGHT_THEME], + langs: [], + }); + } + return highlighterPromise; +} + +async function ensureLanguage(highlighter: Highlighter, lang: string): Promise { + if (!lang || loadedLanguages.has(lang)) return loadedLanguages.has(lang); + try { + await highlighter.loadLanguage(lang as Parameters[0]); + loadedLanguages.add(lang); + return true; + } catch { + return false; + } +} + +export async function highlightCode(code: string, lang: string, isDark: boolean): Promise { + const highlighter = await getHighlighter(); + const hasLang = await ensureLanguage(highlighter, lang); + return highlighter.codeToHtml(code, { + lang: hasLang ? lang : "text", + theme: isDark ? DARK_THEME : LIGHT_THEME, + }); +} diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 325a251dfe..3b5658ee6a 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -324,7 +324,6 @@ export function KanbanCard({ style={style} className={cn( "max-h-48 bg-card rounded-sm data-[size=sm]:py-1 cursor-pointer mb-2 w-full py-0 relative border border-border overflow-visible shadow-none ring-0", - (task.state === "IN_PROGRESS" || task.state === "SCHEDULING") && "kanban-task-pulse", needsAction(task) && "border-l-2 border-l-amber-500", isDragging && "opacity-50 z-50", )} diff --git a/apps/web/components/onboarding-dialog.tsx b/apps/web/components/onboarding-dialog.tsx index b10777daed..c7a5053f8a 100644 --- a/apps/web/components/onboarding-dialog.tsx +++ b/apps/web/components/onboarding-dialog.tsx @@ -26,6 +26,7 @@ import { IconGitCommit, IconTerminal2, IconArrowDown, + IconCloud, } from "@tabler/icons-react"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@kandev/ui/collapsible"; import { Kbd } from "@kandev/ui/kbd"; @@ -65,12 +66,18 @@ const RUNTIMES = [ description: "Containerized execution for full isolation and reproducibility.", icon: IconBrandDocker, }, + { + name: "Sprites (Remote sprites.dev)", + description: "Hardware-isolated execution environment for arbitrary code.", + icon: IconCloud, + href: "https://sprites.dev", + }, ]; -const STEP_TITLES = ["AI Agents", "Environments", "Agentic Workflows", "Command Panel"]; +const STEP_TITLES = ["AI Agents", "Executors", "Agentic Workflows", "Command Panel"]; const STEP_DESCRIPTIONS = [ "These AI coding agents were discovered on your system.", - "Agents can run in different runtime environments.", + "Agents can run in different executor environments — local, containerized, or remote.", "Workflows define the steps and automation for your tasks.", "Quick access to actions from anywhere with a keyboard shortcut.", ]; @@ -374,6 +381,10 @@ function StepAgents({ Expand an agent to configure its model and permissions. Changes are saved when you proceed. You can also add custom TUI agents later in Settings > Agents.

+

+ Careful: The default Agent Profiles run + with with Auto Approve enabled (YOLO mode). +

); } @@ -384,13 +395,25 @@ function StepEnvironments() {
{RUNTIMES.map((runtime) => { const Icon = runtime.icon; + const nameEl = runtime.href ? ( + + {runtime.name} + + ) : ( +

{runtime.name}

+ ); return (
-

{runtime.name}

+ {nameEl}

{runtime.description}

@@ -398,7 +421,7 @@ function StepEnvironments() { })}

- Configure runtime environments in Settings to control where agents execute. + Configure executors in Settings to control where agents execute.

); diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts new file mode 100644 index 0000000000..b675d5159b --- /dev/null +++ b/apps/web/components/task-create-dialog-effects.ts @@ -0,0 +1,227 @@ +"use client"; + +import { useEffect } from "react"; +import type { Repository, Executor, Branch } from "@/lib/types/http"; +import type { AgentProfileOption } from "@/lib/state/slices"; +import { DEFAULT_LOCAL_EXECUTOR_TYPE } from "@/lib/utils"; +import { useToast } from "@/components/toast-provider"; +import { + discoverRepositoriesAction, + listLocalRepositoryBranchesAction, +} from "@/app/actions/workspaces"; +import { getLocalStorage } from "@/lib/local-storage"; +import { STORAGE_KEYS } from "@/lib/settings/constants"; +import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; +import type { + DialogFormState, + StoreSelections, + TaskCreateEffectsArgs, +} from "@/components/task-create-dialog-types"; +import { autoSelectBranch } from "@/components/task-create-dialog-helpers"; + +export function useWorkflowStepsEffect(fs: DialogFormState, workflowId: string | null) { + const { selectedWorkflowId, setFetchedSteps } = fs; + useEffect(() => { + if (!selectedWorkflowId || selectedWorkflowId === workflowId) { + void Promise.resolve().then(() => setFetchedSteps(null)); + return; + } + let cancelled = false; + listWorkflowSteps(selectedWorkflowId) + .then((response) => { + if (cancelled) return; + const sorted = [...response.steps].sort((a, b) => a.position - b.position); + setFetchedSteps(sorted.map((s) => ({ id: s.id, title: s.name, events: s.events }))); + }) + .catch(() => { + if (!cancelled) setFetchedSteps(null); + }); + return () => { + cancelled = true; + }; + }, [selectedWorkflowId, workflowId, setFetchedSteps]); +} + +export function useRepositoryAutoSelectEffect( + fs: DialogFormState, + open: boolean, + workspaceId: string | null, + repositories: Repository[], +) { + const { repositoryId, selectedLocalRepo, setRepositoryId } = fs; + useEffect(() => { + if (!open || !workspaceId || repositoryId || selectedLocalRepo) return; + const lastUsedRepoId = getLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, null); + if (lastUsedRepoId && repositories.some((r: Repository) => r.id === lastUsedRepoId)) { + void Promise.resolve().then(() => setRepositoryId(lastUsedRepoId)); + return; + } + if (repositories.length === 1) + void Promise.resolve().then(() => setRepositoryId(repositories[0].id)); + }, [open, repositories, repositoryId, selectedLocalRepo, workspaceId, setRepositoryId]); +} + +export function useDiscoverReposEffect( + fs: DialogFormState, + open: boolean, + workspaceId: string | null, + repositoriesLoading: boolean, + toast: ReturnType["toast"], +) { + const { + discoverReposLoaded, + discoverReposLoading, + setDiscoveredRepositories, + setDiscoverReposLoading, + setDiscoverReposLoaded, + } = fs; + useEffect(() => { + if (!open || !workspaceId || repositoriesLoading || discoverReposLoaded || discoverReposLoading) + return; + void Promise.resolve() + .then(() => setDiscoverReposLoading(true)) + .then(() => discoverRepositoriesAction(workspaceId)) + .then((r) => { + setDiscoveredRepositories(r.repositories); + }) + .catch((e) => { + toast({ + title: "Failed to discover repositories", + description: e instanceof Error ? e.message : "Request failed", + variant: "error", + }); + setDiscoveredRepositories([]); + }) + .finally(() => { + setDiscoverReposLoading(false); + setDiscoverReposLoaded(true); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + discoverReposLoaded, + discoverReposLoading, + open, + fs.discoveredRepositories.length, + repositoriesLoading, + toast, + workspaceId, + ]); +} + +export function useLocalBranchesEffect( + fs: DialogFormState, + open: boolean, + workspaceId: string | null, + toast: ReturnType["toast"], +) { + const { selectedLocalRepo, setLocalBranches, setLocalBranchesLoading } = fs; + useEffect(() => { + if (!open || !workspaceId || !selectedLocalRepo) return; + const repoPath = selectedLocalRepo.path; + void Promise.resolve() + .then(() => setLocalBranchesLoading(true)) + .then(() => listLocalRepositoryBranchesAction(workspaceId, repoPath)) + .then((r) => { + setLocalBranches(r.branches); + }) + .catch((e) => { + toast({ + title: "Failed to load branches", + description: e instanceof Error ? e.message : "Request failed", + variant: "error", + }); + setLocalBranches([]); + }) + .finally(() => { + setLocalBranchesLoading(false); + }); + }, [open, selectedLocalRepo, toast, workspaceId, setLocalBranches, setLocalBranchesLoading]); +} + +export function useDefaultSelectionsEffect( + fs: DialogFormState, + open: boolean, + sel: StoreSelections, +) { + const { agentProfiles, executors, workspaceDefaults } = sel; + const { + agentProfileId, + executorId, + executorProfileId, + setAgentProfileId, + setExecutorId, + setExecutorProfileId, + } = fs; + useEffect(() => { + if (!open || agentProfileId || agentProfiles.length === 0) return; + const lastId = getLocalStorage(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, null); + if (lastId && agentProfiles.some((p: AgentProfileOption) => p.id === lastId)) { + void Promise.resolve().then(() => setAgentProfileId(lastId)); + return; + } + const defId = workspaceDefaults?.default_agent_profile_id ?? null; + if (defId && agentProfiles.some((p: AgentProfileOption) => p.id === defId)) { + void Promise.resolve().then(() => setAgentProfileId(defId)); + return; + } + void Promise.resolve().then(() => setAgentProfileId(agentProfiles[0].id)); + }, [open, agentProfileId, agentProfiles, workspaceDefaults, setAgentProfileId]); + + useEffect(() => { + if (!open || executorId || executors.length === 0) return; + const defId = workspaceDefaults?.default_executor_id ?? null; + if (defId && executors.some((e: Executor) => e.id === defId)) { + void Promise.resolve().then(() => setExecutorId(defId)); + return; + } + const local = executors.find((e: Executor) => e.type === DEFAULT_LOCAL_EXECUTOR_TYPE); + void Promise.resolve().then(() => setExecutorId(local?.id ?? executors[0].id)); + }, [open, executorId, executors, workspaceDefaults, setExecutorId]); + + useEffect(() => { + // Auto-select executor profile: last used (localStorage) → first available + if (!open || executorProfileId || executors.length === 0) return; + const allProfiles = executors.flatMap((e) => + (e.profiles ?? []).map((p) => ({ ...p, _executorId: e.id })), + ); + if (allProfiles.length === 0) return; + const lastId = getLocalStorage(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID, null); + const pick = lastId && allProfiles.some((p) => p.id === lastId) ? lastId : allProfiles[0].id; + void Promise.resolve().then(() => setExecutorProfileId(pick)); + }, [open, executorProfileId, executors, setExecutorProfileId]); + + // Derive executorId from the selected executor profile + useEffect(() => { + if (!executorProfileId) return; + for (const executor of executors) { + const match = (executor.profiles ?? []).find((p) => p.id === executorProfileId); + if (match) { + void Promise.resolve().then(() => setExecutorId(executor.id)); + return; + } + } + }, [executorProfileId, executors, setExecutorId]); +} + +export function useBranchAutoSelectEffect(fs: DialogFormState, branches: Branch[]) { + const { repositoryId, branch, localBranches, setBranch } = fs; + useEffect(() => { + if (!repositoryId || branch) return; + autoSelectBranch(branches, setBranch); + }, [branch, branches, repositoryId, setBranch]); + useEffect(() => { + if (repositoryId || localBranches.length === 0 || branch) return; + autoSelectBranch(localBranches, setBranch); + }, [branch, localBranches, repositoryId, setBranch]); +} + +export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreateEffectsArgs) { + const { open, workspaceId, workflowId, repositories, repositoriesLoading, branches } = args; + const { agentProfiles, executors, workspaceDefaults, toast } = args; + useWorkflowStepsEffect(fs, workflowId); + useRepositoryAutoSelectEffect(fs, open, workspaceId, repositories); + useDiscoverReposEffect(fs, open, workspaceId, repositoriesLoading, toast); + useBranchAutoSelectEffect(fs, branches); + useLocalBranchesEffect(fs, open, workspaceId, toast); + useDefaultSelectionsEffect(fs, open, { agentProfiles, executors, workspaceDefaults }); +} diff --git a/apps/web/components/task-create-dialog-footer.tsx b/apps/web/components/task-create-dialog-footer.tsx index a6b26a6fe3..55ea54305d 100644 --- a/apps/web/components/task-create-dialog-footer.tsx +++ b/apps/web/components/task-create-dialog-footer.tsx @@ -45,6 +45,7 @@ type StartTaskSplitButtonProps = { disabled: boolean; isEditMode: boolean; onAltAction: () => void; + onPlanModeAction?: () => void; }; function StartTaskSplitButton({ @@ -52,6 +53,7 @@ function StartTaskSplitButton({ disabled, isEditMode, onAltAction, + onPlanModeAction, }: StartTaskSplitButtonProps) { return (
@@ -80,6 +82,15 @@ function StartTaskSplitButton({ + {onPlanModeAction && ( + + + Start task in plan mode + + )} void; onUpdateWithoutAgent: () => void; onCreateWithoutAgent: () => void; + onCreateWithPlanMode?: () => void; }; function isMissingWorkflowCtx( @@ -232,6 +244,7 @@ export const TaskCreateDialogFooter = memo(function TaskCreateDialogFooter( onCancel, onUpdateWithoutAgent, onCreateWithoutAgent, + onCreateWithPlanMode, } = props; const { showStartTask, splitDisabled, defaultDisabled } = computeFooterState(props); @@ -271,6 +284,7 @@ export const TaskCreateDialogFooter = memo(function TaskCreateDialogFooter( disabled={splitDisabled} isEditMode={isEditMode} onAltAction={isEditMode ? onUpdateWithoutAgent : onCreateWithoutAgent} + onPlanModeAction={onCreateWithPlanMode} /> ); } diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index 8b68b96bd7..5d325e0bd3 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -1,9 +1,19 @@ -import type { Task, Branch } from "@/lib/types/http"; +import type { useRouter } from "next/navigation"; +import type { Task, Branch, LocalRepository } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; +import type { AppState } from "@/lib/state/store"; import type { StepType } from "@/components/task-create-dialog-types"; import { selectPreferredBranch } from "@/lib/utils"; import { getLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; +import { useDockviewStore } from "@/lib/state/dockview-store"; +import { useContextFilesStore } from "@/lib/state/context-files-store"; +import { linkToSession } from "@/lib/links"; +import { createTask } from "@/lib/api"; + +type CreateTaskParams = Parameters[0]; + +export type { CreateTaskParams }; export function autoSelectBranch(branchList: Branch[], setBranch: (value: string) => void): void { const lastUsedBranch = getLocalStorage(STORAGE_KEYS.LAST_BRANCH, null); @@ -49,3 +59,78 @@ export function computeIsTaskStarted( if (!isEditMode || !editingTask?.state) return false; return editingTask.state !== "TODO" && editingTask.state !== "CREATED"; } + +export type ActivatePlanModeArgs = { + sessionId: string; + taskId: string; + setActiveDocument: AppState["setActiveDocument"]; + setPlanMode: AppState["setPlanMode"]; + router: ReturnType; +}; + +export function activatePlanMode({ + sessionId, + taskId, + setActiveDocument, + setPlanMode, + router, +}: ActivatePlanModeArgs) { + setActiveDocument(sessionId, { type: "plan", taskId }); + useDockviewStore.getState().queuePanelAction({ + id: "plan", + component: "plan", + title: "Plan", + placement: "right", + referencePanel: "chat", + }); + setPlanMode(sessionId, true); + useContextFilesStore.getState().addFile(sessionId, { path: "plan:context", name: "Plan" }); + router.push(linkToSession(sessionId)); +} + +export type BuildCreatePayloadArgs = { + workspaceId: string; + effectiveWorkflowId: string; + trimmedTitle: string; + trimmedDescription: string; + repositoriesPayload: CreateTaskParams["repositories"]; + agentProfileId: string; + executorId: string; + executorProfileId: string; + withAgent: boolean; + planMode?: boolean; +}; + +export function buildCreateTaskPayload(args: BuildCreatePayloadArgs): CreateTaskParams { + return { + workspace_id: args.workspaceId, + workflow_id: args.effectiveWorkflowId, + title: args.trimmedTitle, + description: args.trimmedDescription, + repositories: args.repositoriesPayload, + state: args.withAgent ? "IN_PROGRESS" : "CREATED", + start_agent: args.withAgent ? true : undefined, + prepare_session: args.withAgent ? undefined : true, + agent_profile_id: args.agentProfileId || undefined, + executor_id: args.executorId || undefined, + executor_profile_id: args.executorProfileId || undefined, + plan_mode: args.planMode || undefined, + }; +} + +export function validateCreateInputs(inputs: { + trimmedTitle: string; + workspaceId: string | null; + effectiveWorkflowId: string | null; + repositoryId: string; + selectedLocalRepo: LocalRepository | null; + agentProfileId: string; +}): boolean { + return Boolean( + inputs.trimmedTitle && + inputs.workspaceId && + inputs.effectiveWorkflowId && + (inputs.repositoryId || inputs.selectedLocalRepo) && + inputs.agentProfileId, + ); +} diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index 3a0971529e..0b06370aa2 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -5,12 +5,9 @@ import type { LocalRepository, Workspace, Repository, - Executor, ExecutorProfile, Branch, } from "@/lib/types/http"; -import type { AgentProfileOption } from "@/lib/state/slices"; -import { DEFAULT_LOCAL_EXECUTOR_TYPE } from "@/lib/utils"; import { useAppStore } from "@/components/state-provider"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { useRepositoryBranches } from "@/hooks/domains/workspace/use-repository-branches"; @@ -22,25 +19,16 @@ import { useExecutorHint, useExecutorProfileOptions, } from "@/components/task-create-dialog-options"; -import { useToast } from "@/components/toast-provider"; -import { - discoverRepositoriesAction, - listLocalRepositoryBranchesAction, -} from "@/app/actions/workspaces"; -import { getLocalStorage, setLocalStorage } from "@/lib/local-storage"; +import { setLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; -import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; import type { StepType, TaskCreateDialogInitialValues, DialogFormState, - StoreSelections, DialogComputedValues, DialogComputedArgs, - TaskCreateEffectsArgs, } from "@/components/task-create-dialog-types"; import { - autoSelectBranch, computePassthroughProfile, computeEffectiveStepId, } from "@/components/task-create-dialog-helpers"; @@ -51,12 +39,6 @@ export type { } from "@/components/task-create-dialog-types"; export { autoSelectBranch } from "@/components/task-create-dialog-helpers"; -function useCreationStatusState() { - const [isCreatingSession, setIsCreatingSession] = useState(false); - const [isCreatingTask, setIsCreatingTask] = useState(false); - return { isCreatingSession, setIsCreatingSession, isCreatingTask, setIsCreatingTask }; -} - type FormResetters = { setTaskName: (v: string) => void; setHasTitle: (v: boolean) => void; @@ -143,8 +125,8 @@ export function useDialogFormState( const [discoverReposLoaded, setDiscoverReposLoaded] = useState(false); const [selectedWorkflowId, setSelectedWorkflowId] = useState(workflowId); const [fetchedSteps, setFetchedSteps] = useState(null); - const { isCreatingSession, setIsCreatingSession, isCreatingTask, setIsCreatingTask } = - useCreationStatusState(); + const [isCreatingSession, setIsCreatingSession] = useState(false); + const [isCreatingTask, setIsCreatingTask] = useState(false); useFormResetEffects(open, workspaceId, workflowId, initialValues, { setTaskName, setHasTitle, @@ -206,200 +188,12 @@ export function useDialogFormState( } export type { DialogFormState } from "@/components/task-create-dialog-types"; - -export function useWorkflowStepsEffect(fs: DialogFormState, workflowId: string | null) { - const { selectedWorkflowId, setFetchedSteps } = fs; - useEffect(() => { - if (!selectedWorkflowId || selectedWorkflowId === workflowId) { - void Promise.resolve().then(() => setFetchedSteps(null)); - return; - } - let cancelled = false; - listWorkflowSteps(selectedWorkflowId) - .then((response) => { - if (cancelled) return; - const sorted = [...response.steps].sort((a, b) => a.position - b.position); - setFetchedSteps(sorted.map((s) => ({ id: s.id, title: s.name, events: s.events }))); - }) - .catch(() => { - if (!cancelled) setFetchedSteps(null); - }); - return () => { - cancelled = true; - }; - }, [selectedWorkflowId, workflowId, setFetchedSteps]); -} - -export function useRepositoryAutoSelectEffect( - fs: DialogFormState, - open: boolean, - workspaceId: string | null, - repositories: Repository[], -) { - const { repositoryId, selectedLocalRepo, setRepositoryId } = fs; - useEffect(() => { - if (!open || !workspaceId || repositoryId || selectedLocalRepo) return; - const lastUsedRepoId = getLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, null); - if (lastUsedRepoId && repositories.some((r: Repository) => r.id === lastUsedRepoId)) { - void Promise.resolve().then(() => setRepositoryId(lastUsedRepoId)); - return; - } - if (repositories.length === 1) - void Promise.resolve().then(() => setRepositoryId(repositories[0].id)); - }, [open, repositories, repositoryId, selectedLocalRepo, workspaceId, setRepositoryId]); -} - -export function useDiscoverReposEffect( - fs: DialogFormState, - open: boolean, - workspaceId: string | null, - repositoriesLoading: boolean, - toast: ReturnType["toast"], -) { - const { - discoverReposLoaded, - discoverReposLoading, - setDiscoveredRepositories, - setDiscoverReposLoading, - setDiscoverReposLoaded, - } = fs; - useEffect(() => { - if (!open || !workspaceId || repositoriesLoading || discoverReposLoaded || discoverReposLoading) - return; - void Promise.resolve() - .then(() => setDiscoverReposLoading(true)) - .then(() => discoverRepositoriesAction(workspaceId)) - .then((r) => { - setDiscoveredRepositories(r.repositories); - }) - .catch((e) => { - toast({ - title: "Failed to discover repositories", - description: e instanceof Error ? e.message : "Request failed", - variant: "error", - }); - setDiscoveredRepositories([]); - }) - .finally(() => { - setDiscoverReposLoading(false); - setDiscoverReposLoaded(true); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - discoverReposLoaded, - discoverReposLoading, - open, - fs.discoveredRepositories.length, - repositoriesLoading, - toast, - workspaceId, - ]); -} - -export function useLocalBranchesEffect( - fs: DialogFormState, - open: boolean, - workspaceId: string | null, - toast: ReturnType["toast"], -) { - const { selectedLocalRepo, setLocalBranches, setLocalBranchesLoading } = fs; - useEffect(() => { - if (!open || !workspaceId || !selectedLocalRepo) return; - const repoPath = selectedLocalRepo.path; - void Promise.resolve() - .then(() => setLocalBranchesLoading(true)) - .then(() => listLocalRepositoryBranchesAction(workspaceId, repoPath)) - .then((r) => { - setLocalBranches(r.branches); - }) - .catch((e) => { - toast({ - title: "Failed to load branches", - description: e instanceof Error ? e.message : "Request failed", - variant: "error", - }); - setLocalBranches([]); - }) - .finally(() => { - setLocalBranchesLoading(false); - }); - }, [open, selectedLocalRepo, toast, workspaceId, setLocalBranches, setLocalBranchesLoading]); -} - -export function useDefaultSelectionsEffect( - fs: DialogFormState, - open: boolean, - sel: StoreSelections, -) { - const { agentProfiles, executors, workspaceDefaults } = sel; - const { - agentProfileId, - executorId, - executorProfileId, - setAgentProfileId, - setExecutorId, - setExecutorProfileId, - } = fs; - useEffect(() => { - if (!open || agentProfileId || agentProfiles.length === 0) return; - const lastId = getLocalStorage(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, null); - if (lastId && agentProfiles.some((p: AgentProfileOption) => p.id === lastId)) { - void Promise.resolve().then(() => setAgentProfileId(lastId)); - return; - } - const defId = workspaceDefaults?.default_agent_profile_id ?? null; - if (defId && agentProfiles.some((p: AgentProfileOption) => p.id === defId)) { - void Promise.resolve().then(() => setAgentProfileId(defId)); - return; - } - void Promise.resolve().then(() => setAgentProfileId(agentProfiles[0].id)); - }, [open, agentProfileId, agentProfiles, workspaceDefaults, setAgentProfileId]); - - useEffect(() => { - if (!open || executorId || executors.length === 0) return; - const defId = workspaceDefaults?.default_executor_id ?? null; - if (defId && executors.some((e: Executor) => e.id === defId)) { - void Promise.resolve().then(() => setExecutorId(defId)); - return; - } - const local = executors.find((e: Executor) => e.type === DEFAULT_LOCAL_EXECUTOR_TYPE); - void Promise.resolve().then(() => setExecutorId(local?.id ?? executors[0].id)); - }, [open, executorId, executors, workspaceDefaults, setExecutorId]); - - // Auto-select first executor profile when none selected - useEffect(() => { - if (!open || executorProfileId || executors.length === 0) return; - const allProfiles = executors.flatMap((e) => - (e.profiles ?? []).map((p) => ({ ...p, _executorId: e.id })), - ); - if (allProfiles.length === 0) return; - void Promise.resolve().then(() => setExecutorProfileId(allProfiles[0].id)); - }, [open, executorProfileId, executors, setExecutorProfileId]); - - // Derive executorId from the selected executor profile - useEffect(() => { - if (!executorProfileId) return; - for (const executor of executors) { - const match = (executor.profiles ?? []).find((p) => p.id === executorProfileId); - if (match) { - void Promise.resolve().then(() => setExecutorId(executor.id)); - return; - } - } - }, [executorProfileId, executors, setExecutorId]); -} - -export function useBranchAutoSelectEffect(fs: DialogFormState, branches: Branch[]) { - const { repositoryId, branch, localBranches, setBranch } = fs; - useEffect(() => { - if (!repositoryId || branch) return; - autoSelectBranch(branches, setBranch); - }, [branch, branches, repositoryId, setBranch]); - useEffect(() => { - if (repositoryId || localBranches.length === 0 || branch) return; - autoSelectBranch(localBranches, setBranch); - }, [branch, localBranches, repositoryId, setBranch]); -} +export { + computePassthroughProfile, + computeEffectiveStepId, + computeIsTaskStarted, +} from "@/components/task-create-dialog-helpers"; +export { useTaskCreateDialogEffects } from "@/components/task-create-dialog-effects"; export function useDialogHandlers(fs: DialogFormState, repositories: Repository[]) { const handleSelectLocalRepository = useCallback( @@ -439,6 +233,7 @@ export function useDialogHandlers(fs: DialogFormState, repositories: Repository[ const handleExecutorProfileChange = useCallback( (value: string) => { fs.setExecutorProfileId(value); + setLocalStorage(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID, value); }, [fs], ); @@ -473,12 +268,6 @@ export function useDialogHandlers(fs: DialogFormState, repositories: Repository[ }; } -export { - computePassthroughProfile, - computeEffectiveStepId, - computeIsTaskStarted, -} from "@/components/task-create-dialog-helpers"; - export function useDialogComputed({ fs, open, @@ -602,28 +391,3 @@ export function useTaskCreateDialogData( computed, }; } - -export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreateEffectsArgs) { - const { - open, - workspaceId, - workflowId, - repositories, - repositoriesLoading, - branches, - agentProfiles, - executors, - workspaceDefaults, - toast, - } = args; - useWorkflowStepsEffect(fs, workflowId); - useRepositoryAutoSelectEffect(fs, open, workspaceId, repositories); - useDiscoverReposEffect(fs, open, workspaceId, repositoriesLoading, toast); - useBranchAutoSelectEffect(fs, branches); - useLocalBranchesEffect(fs, open, workspaceId, toast); - useDefaultSelectionsEffect(fs, open, { - agentProfiles, - executors, - workspaceDefaults, - }); -} diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx index 3bdde4eccb..3236963198 100644 --- a/apps/web/components/task-create-dialog-submit.tsx +++ b/apps/web/components/task-create-dialog-submit.tsx @@ -2,18 +2,18 @@ import { useCallback, FormEvent } from "react"; import { useRouter } from "next/navigation"; -import type { Task } from "@/lib/types/http"; -import type { LocalRepository } from "@/lib/types/http"; +import type { Task, LocalRepository } from "@/lib/types/http"; import { createTask, updateTask } from "@/lib/api"; import { useAppStore } from "@/components/state-provider"; -import type { AppState } from "@/lib/state/store"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useToast } from "@/components/toast-provider"; import { linkToSession } from "@/lib/links"; -import { useDockviewStore } from "@/lib/state/dockview-store"; -import { useContextFilesStore } from "@/lib/state/context-files-store"; - -type CreateTaskParams = Parameters[0]; +import { + activatePlanMode, + buildCreateTaskPayload, + validateCreateInputs, + type CreateTaskParams, +} from "@/components/task-create-dialog-helpers"; const GENERIC_ERROR_MESSAGE = "An error occurred"; @@ -70,98 +70,6 @@ export type SubmitHandlersDeps = { setFetchedSteps: (v: null) => void; }; -type ActivatePlanModeArgs = { - sessionId: string; - taskId: string; - setActiveDocument: AppState["setActiveDocument"]; - setPlanMode: AppState["setPlanMode"]; - router: ReturnType; -}; - -function activatePlanMode({ - sessionId, - taskId, - setActiveDocument, - setPlanMode, - router, -}: ActivatePlanModeArgs) { - setActiveDocument(sessionId, { type: "plan", taskId }); - useDockviewStore.getState().queuePanelAction({ - id: "plan", - component: "plan", - title: "Plan", - placement: "right", - referencePanel: "chat", - }); - setPlanMode(sessionId, true); - useContextFilesStore.getState().addFile(sessionId, { path: "plan:context", name: "Plan" }); - router.push(linkToSession(sessionId)); -} - -type BuildCreatePayloadArgs = { - workspaceId: string; - effectiveWorkflowId: string; - trimmedTitle: string; - trimmedDescription: string; - repositoriesPayload: CreateTaskParams["repositories"]; - agentProfileId: string; - executorId: string; - executorProfileId: string; - withAgent: boolean; -}; - -function buildCreateTaskPayload({ - workspaceId, - effectiveWorkflowId, - trimmedTitle, - trimmedDescription, - repositoriesPayload, - agentProfileId, - executorId, - executorProfileId, - withAgent, -}: BuildCreatePayloadArgs): CreateTaskParams { - return { - workspace_id: workspaceId, - workflow_id: effectiveWorkflowId, - title: trimmedTitle, - description: trimmedDescription, - repositories: repositoriesPayload, - state: withAgent ? "IN_PROGRESS" : "CREATED", - start_agent: withAgent ? true : undefined, - prepare_session: withAgent ? undefined : true, - agent_profile_id: agentProfileId || undefined, - executor_id: executorId || undefined, - executor_profile_id: executorProfileId || undefined, - }; -} - -type CreateInputs = { - trimmedTitle: string; - workspaceId: string | null; - effectiveWorkflowId: string | null; - repositoryId: string; - selectedLocalRepo: LocalRepository | null; - agentProfileId: string; -}; - -function validateCreateInputs({ - trimmedTitle, - workspaceId, - effectiveWorkflowId, - repositoryId, - selectedLocalRepo, - agentProfileId, -}: CreateInputs): boolean { - return Boolean( - trimmedTitle && - workspaceId && - effectiveWorkflowId && - (repositoryId || selectedLocalRepo) && - agentProfileId, - ); -} - // eslint-disable-next-line max-lines-per-function export function useTaskSubmitHandlers({ isSessionMode, @@ -250,7 +158,6 @@ export function useTaskSubmitHandlers({ if (onCreateSession) { onCreateSession({ prompt: trimmedDescription, agentProfileId, executorId }); - resetForm(); onOpenChange(false); return; } @@ -274,7 +181,6 @@ export function useTaskSubmitHandlers({ 15000, ); - resetForm(); const newSessionId = response?.session_id; if (newSessionId) { router.push(linkToSession(newSessionId)); @@ -297,7 +203,6 @@ export function useTaskSubmitHandlers({ isPassthroughProfile, onCreateSession, onOpenChange, - resetForm, router, taskId, toast, @@ -361,9 +266,8 @@ export function useTaskSubmitHandlers({ variant: "error", }); } finally { - resetForm(); - setIsCreatingTask(false); onOpenChange(false); + setIsCreatingTask(false); } }, [ performTaskUpdate, @@ -371,7 +275,6 @@ export function useTaskSubmitHandlers({ executorId, executorProfileId, onSuccess, - resetForm, onOpenChange, toast, setIsCreatingTask, @@ -390,17 +293,17 @@ export function useTaskSubmitHandlers({ variant: "error", }); } finally { - resetForm(); - setIsCreatingTask(false); onOpenChange(false); + setIsCreatingTask(false); } - }, [performTaskUpdate, onSuccess, onOpenChange, resetForm, toast, setIsCreatingTask]); + }, [performTaskUpdate, onSuccess, onOpenChange, toast, setIsCreatingTask]); - const handleCreateWithAgent = useCallback( + const performCreateWithAgent = useCallback( async ( trimmedTitle: string, trimmedDescription: string, repositoriesPayload: CreateTaskParams["repositories"], + planMode?: boolean, ) => { if (!workspaceId || !effectiveWorkflowId) return; const taskResponse = await createTask( @@ -414,12 +317,21 @@ export function useTaskSubmitHandlers({ executorId, executorProfileId, withAgent: true, + planMode, }), ); const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null; onSuccess?.(taskResponse, "create", { taskSessionId: newSessionId }); - resetForm(); - if (isPassthroughProfile && newSessionId) { + if (planMode && newSessionId) { + onOpenChange(false); + activatePlanMode({ + sessionId: newSessionId, + taskId: taskResponse.id, + setActiveDocument, + setPlanMode, + router, + }); + } else if (isPassthroughProfile && newSessionId) { router.push(linkToSession(newSessionId)); } else { onOpenChange(false); @@ -433,8 +345,9 @@ export function useTaskSubmitHandlers({ executorProfileId, isPassthroughProfile, onSuccess, - resetForm, onOpenChange, + setActiveDocument, + setPlanMode, router, ], ); @@ -457,7 +370,6 @@ export function useTaskSubmitHandlers({ ); const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null; onSuccess?.(taskResponse, "create", { taskSessionId: newSessionId }); - resetForm(); if (newSessionId) { activatePlanMode({ sessionId: newSessionId, @@ -477,7 +389,6 @@ export function useTaskSubmitHandlers({ executorId, executorProfileId, onSuccess, - resetForm, onOpenChange, setActiveDocument, setPlanMode, @@ -485,6 +396,100 @@ export function useTaskSubmitHandlers({ ], ); + const performEditWithPlanMode = useCallback(async () => { + const result = await performTaskUpdate(); + if (!result) return; + const { updatedTask, trimmedDescription } = result; + const client = getWebSocketClient(); + if (!client) throw new Error("WebSocket client not available"); + const response = await client.request( + "orchestrator.start", + { + task_id: updatedTask.id, + agent_profile_id: agentProfileId, + executor_id: executorId, + executor_profile_id: executorProfileId || undefined, + prompt: trimmedDescription || "", + plan_mode: true, + }, + 15000, + ); + const newSessionId = response?.session_id ?? null; + onSuccess?.(updatedTask, "edit", { taskSessionId: newSessionId }); + onOpenChange(false); + if (newSessionId) { + activatePlanMode({ + sessionId: newSessionId, + taskId: updatedTask.id, + setActiveDocument, + setPlanMode, + router, + }); + } + }, [ + performTaskUpdate, + agentProfileId, + executorId, + executorProfileId, + onSuccess, + onOpenChange, + setActiveDocument, + setPlanMode, + router, + ]); + + const handleCreateWithPlanMode = useCallback(async () => { + setIsCreatingTask(true); + try { + if (isEditMode) { + await performEditWithPlanMode(); + } else { + const trimmedTitle = taskName.trim(); + const description = descriptionInputRef.current?.getValue() ?? ""; + const trimmedDescription = description.trim(); + if ( + !validateCreateInputs({ + trimmedTitle, + workspaceId, + effectiveWorkflowId, + repositoryId, + selectedLocalRepo, + agentProfileId, + }) + ) + return; + await performCreateWithAgent( + trimmedTitle, + trimmedDescription, + getRepositoriesPayload(), + true, + ); + } + } catch (error) { + toast({ + title: "Failed to start task in plan mode", + description: error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE, + variant: "error", + }); + } finally { + setIsCreatingTask(false); + } + }, [ + isEditMode, + performEditWithPlanMode, + taskName, + workspaceId, + effectiveWorkflowId, + repositoryId, + selectedLocalRepo, + agentProfileId, + getRepositoriesPayload, + performCreateWithAgent, + toast, + descriptionInputRef, + setIsCreatingTask, + ]); + const handleCreateSubmit = useCallback(async () => { const trimmedTitle = taskName.trim(); const description = descriptionInputRef.current?.getValue() ?? ""; @@ -504,7 +509,7 @@ export function useTaskSubmitHandlers({ setIsCreatingTask(true); try { if (trimmedDescription || isPassthroughProfile) { - await handleCreateWithAgent(trimmedTitle, trimmedDescription, repositoriesPayload); + await performCreateWithAgent(trimmedTitle, trimmedDescription, repositoriesPayload); } else { await handleCreatePlanMode(trimmedTitle, repositoriesPayload); } @@ -526,7 +531,7 @@ export function useTaskSubmitHandlers({ agentProfileId, isPassthroughProfile, getRepositoriesPayload, - handleCreateWithAgent, + performCreateWithAgent, handleCreatePlanMode, toast, descriptionInputRef, @@ -568,7 +573,6 @@ export function useTaskSubmitHandlers({ executor_profile_id: executorProfileId || undefined, }); onSuccess?.(taskResponse, "create"); - resetForm(); onOpenChange(false); } catch (error) { toast({ @@ -592,7 +596,6 @@ export function useTaskSubmitHandlers({ getRepositoriesPayload, onSuccess, onOpenChange, - resetForm, toast, descriptionInputRef, setIsCreatingTask, @@ -618,6 +621,7 @@ export function useTaskSubmitHandlers({ handleSubmit, handleUpdateWithoutAgent, handleCreateWithoutAgent, + handleCreateWithPlanMode, handleCancel, }; } diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 166673a3b7..098ce84f61 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -382,25 +382,12 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { export function TaskCreateDialog(props: TaskCreateDialogProps) { const { open, onOpenChange, initialValues, workspaceId } = props; - const { - fs, - isSessionMode, - isEditMode, - isCreateMode, - isTaskStarted, - sessionRepoName, - workflows, - agentProfiles, - snapshots, - repositoriesLoading, - branchesLoading, - computed, - handlers, - submitHandlers, - handleKeyDown, - } = useTaskCreateDialogSetup(props); - const { handleSubmit, handleUpdateWithoutAgent, handleCreateWithoutAgent, handleCancel } = - submitHandlers; + const setup = useTaskCreateDialogSetup(props); + const { fs, isSessionMode, isEditMode, isCreateMode, isTaskStarted } = setup; + const { sessionRepoName, workflows, agentProfiles, snapshots } = setup; + const { repositoriesLoading, branchesLoading, computed, handlers, handleKeyDown } = setup; + const { handleSubmit, handleUpdateWithoutAgent, handleCreateWithoutAgent } = setup.submitHandlers; + const { handleCreateWithPlanMode, handleCancel } = setup.submitHandlers; return ( @@ -470,6 +457,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) { onCancel={handleCancel} onUpdateWithoutAgent={handleUpdateWithoutAgent} onCreateWithoutAgent={handleCreateWithoutAgent} + onCreateWithPlanMode={handleCreateWithPlanMode} /> diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index a34f38f740..20365ea874 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -15,15 +15,43 @@ import { type QueuedMessageIndicatorHandle } from "@/components/task/chat/queued import { formatReviewCommentsAsMarkdown, formatPRFeedbackAsMarkdown, + formatPlanCommentsAsMarkdown, } from "@/lib/state/slices/comments/format"; import type { DiffComment } from "@/lib/diff/types"; import type { useChatPanelState } from "./use-chat-panel-state"; +const PLAN_CONTEXT_PATH = "plan:context"; + +function buildSubmitMessage( + message: string, + reviewComments: DiffComment[] | undefined, + pendingPRFeedback: import("@/lib/state/slices/comments").PRFeedbackComment[], + planComments: import("@/lib/state/slices/comments").PlanComment[], +): string { + let finalMessage = message; + if (reviewComments && reviewComments.length > 0) { + finalMessage = formatReviewCommentsAsMarkdown(reviewComments) + (message || ""); + } + if (pendingPRFeedback.length > 0) { + finalMessage = formatPRFeedbackAsMarkdown(pendingPRFeedback) + finalMessage; + } + if (planComments.length > 0) { + const planMarkdown = formatPlanCommentsAsMarkdown(planComments); + const header = "### Plan Comments\n\n"; + finalMessage = finalMessage + ? `${header}${planMarkdown}\n\n---\n\n${finalMessage}` + : `${header}${planMarkdown}`; + } + return finalMessage; +} + function resolveInputPlaceholder( isAgentBusy: boolean, activeDocumentType: string | undefined, planModeEnabled: boolean, + hasClarification: boolean, ): string { + if (hasClarification) return "Answer the question above to continue..."; if (isAgentBusy) return "Queue instructions to the agent..."; if (activeDocumentType === "file") return "Continue working on the file..."; if (planModeEnabled) return "Continue working on the plan..."; @@ -39,7 +67,6 @@ export function useSubmitHandler( resolvedSessionId, sessionModel, activeModel, - planContextEnabled, isAgentBusy, activeDocument, planComments, @@ -50,13 +77,15 @@ export function useSubmitHandler( clearSessionPlanComments, handleClearPRFeedback, clearEphemeral, + addContextFile, + planModeEnabled, } = panelState; const { handleSendMessage } = useMessageHandler({ resolvedSessionId, taskId: panelState.taskId, sessionModel, activeModel, - planMode: planContextEnabled, + planModeEnabled: panelState.planModeEnabled, isAgentBusy, activeDocument, planComments, @@ -74,15 +103,12 @@ export function useSubmitHandler( if (isSending) return; setIsSending(true); try { - let finalMessage = message; - if (reviewComments && reviewComments.length > 0) { - const reviewMarkdown = formatReviewCommentsAsMarkdown(reviewComments); - finalMessage = reviewMarkdown + (message ? message : ""); - } - if (pendingPRFeedback.length > 0) { - const prMarkdown = formatPRFeedbackAsMarkdown(pendingPRFeedback); - finalMessage = prMarkdown + finalMessage; - } + const finalMessage = buildSubmitMessage( + message, + reviewComments, + pendingPRFeedback, + planComments, + ); const hasReviewComments = !!(reviewComments && reviewComments.length > 0); if (onSend) { await onSend(finalMessage); @@ -93,7 +119,13 @@ export function useSubmitHandler( markCommentsSent(reviewComments.map((c) => c.id)); if (pendingPRFeedback.length > 0) handleClearPRFeedback(); if (planComments.length > 0) clearSessionPlanComments(); - if (resolvedSessionId) clearEphemeral(resolvedSessionId); + if (resolvedSessionId) { + clearEphemeral(resolvedSessionId); + // Re-add plan context if plan mode is still active (clearEphemeral removes unpinned files) + if (planModeEnabled) { + addContextFile(resolvedSessionId, { path: PLAN_CONTEXT_PATH, name: "Plan" }); + } + } } finally { setIsSending(false); } @@ -103,12 +135,14 @@ export function useSubmitHandler( onSend, handleSendMessage, markCommentsSent, - planComments.length, + planComments, clearSessionPlanComments, pendingPRFeedback, handleClearPRFeedback, resolvedSessionId, clearEphemeral, + planModeEnabled, + addContextFile, ], ); @@ -167,6 +201,46 @@ export function useChatPanelHandlers( return { handleCancelTurn, handleCancelQueue, handleQueueEditComplete }; } +function useImplementPlan( + resolvedSessionId: string | null, + taskId: string | null, + handlePlanModeChange: (enabled: boolean) => void, + chatInputRef: React.RefObject, +) { + return useCallback(() => { + if (!resolvedSessionId || !taskId) return; + + const client = getWebSocketClient(); + if (!client) return; + + const userText = chatInputRef.current?.getValue() ?? ""; + chatInputRef.current?.clear(); + + const visibleText = userText.trim() || "Implement the plan"; + const content = `${visibleText}\n\n +IMPLEMENT PLAN: The user has approved the plan and wants you to implement it now. +Read the current plan using the get_task_plan_kandev MCP tool. +Implement all changes described in the plan step by step. +After completing the implementation, provide a summary of what was done. +`; + + client + .request( + "message.add", + { + task_id: taskId, + session_id: resolvedSessionId, + content, + plan_mode: false, + }, + 10000, + ) + .catch((err: unknown) => console.error("Failed to send implement plan message:", err)); + + handlePlanModeChange(false); + }, [resolvedSessionId, taskId, handlePlanModeChange, chatInputRef]); +} + type ChatInputAreaProps = { chatInputRef: React.RefObject; queuedMessageRef: React.RefObject; @@ -227,7 +301,20 @@ export function ChatInputArea({ planContextEnabled, todoItems, } = panelState; - const placeholder = resolveInputPlaceholder(isAgentBusy, activeDocument?.type, planModeEnabled); + const handleImplementPlan = useImplementPlan( + resolvedSessionId, + taskId, + handlePlanModeChange, + chatInputRef, + ); + + const hasClarification = !!panelState.pendingClarification; + const placeholder = resolveInputPlaceholder( + isAgentBusy, + activeDocument?.type, + planModeEnabled, + hasClarification, + ); return (
0 || panelState.pendingPRFeedback.length > 0 + } submitKey={chatSubmitKey} hasAgentCommands={!!(agentCommands && agentCommands.length > 0)} isFailed={isFailed} @@ -266,6 +358,7 @@ export function ChatInputArea({ queuedMessageRef={queuedMessageRef} onQueueEditComplete={handleQueueEditComplete} isPanelFocused={isPanelFocused} + onImplementPlan={handleImplementPlan} />
); diff --git a/apps/web/components/task/chat/chat-input-body.tsx b/apps/web/components/task/chat/chat-input-body.tsx index eea97a89c3..525aa87ec6 100644 --- a/apps/web/components/task/chat/chat-input-body.tsx +++ b/apps/web/components/task/chat/chat-input-body.tsx @@ -3,7 +3,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@/lib/utils"; import { TipTapInput } from "./tiptap-input"; -import { ClarificationInputOverlay } from "./clarification-input-overlay"; import { ChatInputFocusHint } from "./chat-input-focus-hint"; import { ResizeHandle } from "./resize-handle"; import { TodoSummary } from "./todo-summary"; @@ -15,7 +14,6 @@ import { ChatInputToolbar } from "./chat-input-toolbar"; import { ContextZone } from "./context-items/context-zone"; import type { ContextItem } from "@/lib/types/context"; import type { ContextFile } from "@/lib/state/context-files-store"; -import type { Message } from "@/lib/types/http"; type TodoItem = { text: string; done?: boolean }; @@ -28,6 +26,8 @@ export type ChatInputEditorAreaProps = { isDisabled: boolean; hasClarification: boolean; planModeEnabled: boolean; + planModeAvailable: boolean; + mcpServers: string[]; submitKey: "enter" | "cmd_enter"; setIsInputFocused: (focused: boolean) => void; sessionId: string | null; @@ -48,6 +48,7 @@ export type ChatInputEditorAreaProps = { contextPopoverOpen: boolean; setContextPopoverOpen: (open: boolean) => void; contextFiles: ContextFile[]; + onImplementPlan?: () => void; }; export function ChatInputEditorArea({ @@ -59,6 +60,8 @@ export function ChatInputEditorArea({ isDisabled, hasClarification, planModeEnabled, + planModeAvailable, + mcpServers, submitKey, setIsInputFocused, sessionId, @@ -79,6 +82,7 @@ export function ChatInputEditorArea({ contextPopoverOpen, setContextPopoverOpen, contextFiles, + onImplementPlan, }: ChatInputEditorAreaProps) { return (
@@ -103,6 +107,7 @@ export function ChatInputEditorArea({ planContextEnabled={planContextEnabled} onAgentCommand={handleAgentCommand} onImagePaste={handleImagePaste} + onPlanModeChange={onPlanModeChange} />
@@ -112,6 +117,8 @@ export function ChatInputEditorArea({
); @@ -146,9 +154,6 @@ export type ChatInputContextAreaProps = { onQueueEditComplete?: () => void; hasTodos: boolean; todoItems: TodoItem[]; - hasClarification: boolean; - pendingClarification?: Message | null; - onClarificationResolved?: () => void; }; export function ChatInputContextArea({ @@ -163,9 +168,6 @@ export function ChatInputContextArea({ onQueueEditComplete, hasTodos, todoItems, - hasClarification, - pendingClarification, - onClarificationResolved, }: ChatInputContextAreaProps) { if (!hasContextZone) return null; const queueSlot = hasQueuedMessage ? ( @@ -179,22 +181,8 @@ export function ChatInputContextArea({ /> ) : undefined; const todoSlot = hasTodos ? : undefined; - const clarificationSlot = hasClarification ? ( -
- -
- ) : undefined; return ( - + ); } @@ -208,6 +196,7 @@ export type ChatInputBodyProps = { hasClarification: boolean; showRequestChangesTooltip: boolean; hasPendingComments: boolean; + planModeEnabled: boolean; showFocusHint: boolean; contextAreaProps: ChatInputContextAreaProps; editorAreaProps: ChatInputEditorAreaProps; @@ -223,24 +212,32 @@ export function ChatInputBody({ hasClarification, showRequestChangesTooltip, hasPendingComments, + planModeEnabled, showFocusHint, contextAreaProps, editorAreaProps, }: ChatInputBodyProps) { + console.log("[ChatInputBody] planModeEnabled:", planModeEnabled); return (
- + diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx index e3d61b318e..69e19e41e2 100644 --- a/apps/web/components/task/chat/chat-input-container.tsx +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -32,6 +32,7 @@ export type ChatInputContainerHandle = { getValue: () => string; getSelectionStart: () => number; insertText: (text: string, from: number, to: number) => void; + clear: () => void; }; type TodoItem = { text: string; done?: boolean }; @@ -48,6 +49,8 @@ type ChatInputContainerProps = { taskTitle?: string; taskDescription: string; planModeEnabled: boolean; + planModeAvailable?: boolean; + mcpServers?: string[]; onPlanModeChange: (enabled: boolean) => void; isAgentBusy: boolean; isStarting: boolean; @@ -59,6 +62,7 @@ type ChatInputContainerProps = { showRequestChangesTooltip?: boolean; onRequestChangesTooltipDismiss?: () => void; pendingCommentsByFile?: Record; + hasContextComments?: boolean; submitKey?: "enter" | "cmd_enter"; hasAgentCommands?: boolean; isFailed?: boolean; @@ -75,6 +79,7 @@ type ChatInputContainerProps = { queuedMessageRef?: React.RefObject; onQueueEditComplete?: () => void; isPanelFocused?: boolean; + onImplementPlan?: () => void; }; function FailedSessionBanner({ @@ -142,9 +147,6 @@ function buildContextAreaProps( onQueueEditComplete: p.onQueueEditComplete, hasTodos: s.hasTodos, todoItems: p.todoItems ?? [], - hasClarification: s.hasClarification, - pendingClarification: p.pendingClarification, - onClarificationResolved: p.onClarificationResolved, }; } @@ -161,6 +163,8 @@ function buildEditorAreaProps( isDisabled: s.isDisabled, hasClarification: s.hasClarification, planModeEnabled: p.planModeEnabled, + planModeAvailable: p.planModeAvailable ?? true, + mcpServers: p.mcpServers ?? [], submitKey: p.submitKey ?? "cmd_enter", setIsInputFocused: s.setIsInputFocused, sessionId: p.sessionId, @@ -181,6 +185,7 @@ function buildEditorAreaProps( contextPopoverOpen: s.contextPopoverOpen, setContextPopoverOpen: s.setContextPopoverOpen, contextFiles: p.contextFiles ?? [], + onImplementPlan: p.onImplementPlan, }; } @@ -226,6 +231,7 @@ export const ChatInputContainer = forwardRef void; sessionId: string | null; taskId: string | null; @@ -34,12 +44,14 @@ export type ChatInputToolbarProps = { planContextEnabled?: boolean; contextFiles?: ContextFile[]; onToggleFile?: (file: ContextFile) => void; + onImplementPlan?: () => void; }; type SubmitButtonProps = { isAgentBusy: boolean; isDisabled: boolean; isSending: boolean; + planModeEnabled: boolean; onCancel: () => void; onSubmit: () => void; submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS]; @@ -49,12 +61,17 @@ function SubmitButton({ isAgentBusy, isDisabled, isSending, + planModeEnabled, onCancel, onSubmit, submitShortcut, }: SubmitButtonProps) { return ( - + {isAgentBusy ? ( )} ); } +function PlanToggleButton({ + planModeEnabled, + planModeAvailable, + onPlanModeChange, +}: { + planModeEnabled: boolean; + planModeAvailable: boolean; + onPlanModeChange: (enabled: boolean) => void; +}) { + const tooltip = planModeAvailable + ? "Toggle plan mode (Shift+Tab) — Agent collaborates on the plan without implementing changes" + : "Toggle plan layout (Shift+Tab) — View and edit the plan (agent cannot read/write it without MCP)"; + + return ( + + + + + {tooltip} + + ); +} + +function ImplementPlanButton({ onClick }: { onClick: () => void }) { + return ( + + + + + Implement the plan + + ); +} + +function McpIndicator({ mcpServers }: { mcpServers: string[] }) { + const hasMcp = mcpServers.length > 0; + const tooltipText = hasMcp + ? `MCP Servers: ${mcpServers.join(", ")}` + : "Agent does not support MCP"; + + return ( + + +
+ {hasMcp ? ( + + ) : ( + + )} +
+
+ {tooltipText} +
+ ); +} + export const ChatInputToolbar = memo(function ChatInputToolbar({ planModeEnabled, + planModeAvailable = true, + mcpServers = [], onPlanModeChange, sessionId, taskId, @@ -104,29 +205,20 @@ export const ChatInputToolbar = memo(function ChatInputToolbar({ planContextEnabled = false, contextFiles = [], onToggleFile, + onImplementPlan, }: ChatInputToolbarProps) { const submitShortcut = submitKey === "enter" ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT; return (
- - - - - Toggle plan mode - + + + + {planModeEnabled && !isAgentBusy && onImplementPlan && ( + + )}
void) { - if (result === "ok") onResolved(); - else if (result === RESULT_EXPIRED) window.location.reload(); + if (result === "ok" || result === RESULT_EXPIRED) onResolved(); } type ClarificationOptionsProps = { @@ -184,13 +183,7 @@ function useClarificationHandlers({ rejected: true, reject_reason: "User skipped", }); - if (result === "ok") onResolved(); - else if (result === RESULT_EXPIRED) { - alert( - "This question has timed out. The agent has already moved on. Please refresh the page.", - ); - onResolved(); - } + handleClarificationResult(result, onResolved); } catch (error) { console.error("Failed to skip clarification:", error); } finally { diff --git a/apps/web/components/task/chat/context-items/context-zone.tsx b/apps/web/components/task/chat/context-items/context-zone.tsx index 81ec8ab3eb..8787d45d24 100644 --- a/apps/web/components/task/chat/context-items/context-zone.tsx +++ b/apps/web/components/task/chat/context-items/context-zone.tsx @@ -9,17 +9,10 @@ type ContextZoneProps = { sessionId?: string | null; queueSlot?: ReactNode; todoSlot?: ReactNode; - clarificationSlot?: ReactNode; }; -export function ContextZone({ - items, - sessionId, - queueSlot, - todoSlot, - clarificationSlot, -}: ContextZoneProps) { - const hasContent = !!queueSlot || !!todoSlot || items.length > 0 || !!clarificationSlot; +export function ContextZone({ items, sessionId, queueSlot, todoSlot }: ContextZoneProps) { + const hasContent = !!queueSlot || !!todoSlot || items.length > 0; if (!hasContent) return null; return ( @@ -34,7 +27,6 @@ export function ContextZone({ ))}
)} - {clarificationSlot}
); diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index 99d0afcc98..d0f7302ead 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -156,7 +156,10 @@ const adapters: MessageAdapter[] = [ { matches: () => true, render: (comment, ctx) => { - if (comment.author_type === "user" || (ctx.isTaskDescription && ctx.sessionState !== "FAILED")) { + if ( + comment.author_type === "user" || + (ctx.isTaskDescription && ctx.sessionState !== "FAILED") + ) { return ( , +): React.ReactNode { + switch (config.icon) { + case "error": + return ; + case "warning": + return ; + case "spinner": + return ( + + ); + default: + return null; + } +} + export function AgentStatus({ sessionState, sessionId, messages = [] }: AgentStatusProps) { const config = sessionState ? STATE_CONFIG[sessionState] : null; const isRunning = config?.icon === "spinner"; - const isError = config?.icon === "error"; - const isWarning = config?.icon === "warning"; - const { isTurnActive, runningDuration, elapsedSeconds, displayDuration } = useAgentStatusData( - sessionId, - messages, - isRunning, - ); + const runningData = useAgentStatusData(sessionId, messages, isRunning); - if (isError && config) return ; - if (isWarning && config) return ; - if (isRunning && config) - return ( - - ); + if (config?.icon) { + return renderActiveStatus({ label: config.label, icon: config.icon }, sessionId, runningData); + } - if (!isTurnActive && displayDuration) { + const displayDuration = runningData.displayDuration; + if (!runningData.isTurnActive && displayDuration) { return (
diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx index d7990c130c..833439ab63 100644 --- a/apps/web/components/task/chat/messages/chat-message.tsx +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -159,9 +159,10 @@ function renderUserMessageBody( showRaw: boolean, hasAttachments: boolean, content: string, + rawContent?: string, ): React.ReactNode { if (hasContent && showRaw) { - return
{content}
; + return
{rawContent || content}
; } if (hasContent) { return ( @@ -190,6 +191,7 @@ type UserMessageMetadata = { attachments?: Array<{ type: string; data: string; mime_type: string }>; plan_mode?: boolean; has_review_comments?: boolean; + has_hidden_prompts?: boolean; context_files?: Array<{ path: string; name: string }>; }; @@ -199,6 +201,7 @@ function parseUserMessageMetadata(comment: Message) { const contextFiles = metadata?.context_files || []; const hasPlanMode = !!metadata?.plan_mode; const hasReviewComments = !!metadata?.has_review_comments; + const hasHiddenPrompts = !!metadata?.has_hidden_prompts; const hasContent = !!(comment.content && comment.content.trim() !== ""); const hasAttachments = imageAttachments.length > 0; return { @@ -206,6 +209,7 @@ function parseUserMessageMetadata(comment: Message) { contextFiles, hasPlanMode, hasReviewComments, + hasHiddenPrompts, hasContent, hasAttachments, }; @@ -224,7 +228,7 @@ function UserContextBadges({ return (
{hasPlanMode && ( - + Plan mode )} @@ -267,6 +271,7 @@ function UserMessageContent({ contextFiles, hasPlanMode, hasReviewComments, + hasHiddenPrompts, hasContent, hasAttachments, } = parseUserMessageMetadata(comment); @@ -279,7 +284,7 @@ function UserMessageContent({ hasReviewComments={hasReviewComments} contextFiles={contextFiles} /> -
+
{hasAttachments && (
{imageAttachments.map((att, index) => ( @@ -294,13 +299,20 @@ function UserMessageContent({ ))}
)} - {renderUserMessageBody(hasContent, showRaw, hasAttachments, comment.content)} + {renderUserMessageBody( + hasContent, + showRaw, + hasAttachments, + comment.content, + comment.raw_content, + )}
0} isRawView={showRaw} onToggleRaw={onToggleRaw} @@ -331,7 +343,7 @@ type AgentMessageProps = { function AgentMessageContent({ comment, showRaw, onToggleRaw, showRichBlocks }: AgentMessageProps) { return (
-
+
{showRaw ? (
             {comment.content || "(empty)"}
@@ -378,7 +390,7 @@ export const ChatMessage = memo(function ChatMessage({
   // Keep the old card-based layout for task descriptions (amber banner)
   if (label === "Task") {
     return (
-      
+

{comment.requests_input ? ( diff --git a/apps/web/components/task/chat/messages/code-block.tsx b/apps/web/components/task/chat/messages/code-block.tsx index d997fedda5..515f6bfc84 100644 --- a/apps/web/components/task/chat/messages/code-block.tsx +++ b/apps/web/components/task/chat/messages/code-block.tsx @@ -3,6 +3,7 @@ import { useEditorProvider } from "@/hooks/use-editor-resolver"; import { MonacoCodeBlock } from "@/components/editors/monaco/monaco-code-block"; import { CodeMirrorCodeBlock } from "@/components/editors/codemirror/codemirror-code-block"; +import { ShikiCodeBlock } from "@/components/editors/shiki/shiki-code-block"; type CodeBlockProps = { children: React.ReactNode; @@ -11,9 +12,7 @@ type CodeBlockProps = { export function CodeBlock(props: CodeBlockProps) { const provider = useEditorProvider("chat-code-block"); - return provider === "monaco" ? ( - - ) : ( - - ); + if (provider === "monaco") return ; + if (provider === "shiki") return ; + return ; } diff --git a/apps/web/components/task/chat/messages/message-actions.tsx b/apps/web/components/task/chat/messages/message-actions.tsx index b54b7c28f1..d77d9c13f0 100644 --- a/apps/web/components/task/chat/messages/message-actions.tsx +++ b/apps/web/components/task/chat/messages/message-actions.tsx @@ -6,6 +6,7 @@ import { IconCode, IconChevronLeft, IconChevronRight, + IconEyeCode, } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { formatRelativeTime } from "@/lib/utils"; @@ -22,6 +23,7 @@ type MessageActionsProps = { showCopy?: boolean; showTimestamp?: boolean; showRawToggle?: boolean; + hasHiddenPrompts?: boolean; showNavigation?: boolean; showModel?: boolean; isRawView?: boolean; @@ -112,23 +114,27 @@ function NavigationButtons({ function RawToggleButton({ isRawView, onToggleRaw, + hasHiddenPrompts, }: { isRawView: boolean; onToggleRaw: () => void; + hasHiddenPrompts?: boolean; }) { return ( ); } @@ -163,6 +169,7 @@ export function MessageActions({ showCopy = true, showTimestamp = true, showRawToggle = true, + hasHiddenPrompts = false, showNavigation = false, showModel = false, isRawView = false, @@ -182,7 +189,11 @@ export function MessageActions({

{showCopy && } {showRawToggle && onToggleRaw && ( - + )} {showNavigation && ( void; onDoubleClick: () => void; }; -export function ResizeHandle({ visible, onMouseDown, onDoubleClick }: ResizeHandleProps) { +export function ResizeHandle({ + visible, + planModeEnabled, + onMouseDown, + onDoubleClick, +}: ResizeHandleProps) { return ( ); } diff --git a/apps/web/components/task/chat/tiptap-input.tsx b/apps/web/components/task/chat/tiptap-input.tsx index 2eb08b8637..3a3bd17184 100644 --- a/apps/web/components/task/chat/tiptap-input.tsx +++ b/apps/web/components/task/chat/tiptap-input.tsx @@ -52,6 +52,7 @@ type TipTapInputProps = { planContextEnabled?: boolean; onAgentCommand?: (commandName: string) => void; onImagePaste?: (files: File[]) => void; + onPlanModeChange?: (enabled: boolean) => void; }; // ── Filter items ──────────────────────────────────────────────────── @@ -341,6 +342,7 @@ export const TipTapInput = forwardRef(funct disabled = false, className, planModeEnabled = false, + onPlanModeChange, submitKey = "cmd_enter", onFocus, onBlur, @@ -385,6 +387,7 @@ export const TipTapInput = forwardRef(funct disabled, className, planModeEnabled, + onPlanModeChange, submitKey, onFocus, onBlur, diff --git a/apps/web/components/task/chat/use-chat-input-container.ts b/apps/web/components/task/chat/use-chat-input-container.ts index 8a0d562fa9..e7fed4e778 100644 --- a/apps/web/components/task/chat/use-chat-input-container.ts +++ b/apps/web/components/task/chat/use-chat-input-container.ts @@ -26,6 +26,7 @@ type UseChatInputContainerParams = { pendingClarification: Message | null | undefined; onClarificationResolved: (() => void) | undefined; pendingCommentsByFile: Record | undefined; + hasContextComments: boolean; queuedMessage: string | null | undefined; onCancelQueue: (() => void) | undefined; updateQueueContent: ((content: string) => Promise) | undefined; @@ -55,6 +56,7 @@ function useInputHandle( insertText: (text: string, from: number, to: number) => { inputRef.current?.insertText(text, from, to); }, + clear: () => inputRef.current?.clear(), }), [inputRef], ); @@ -101,8 +103,7 @@ function computeDerivedState(params: { params.updateQueueContent ); const hasTodos = params.todoItems.length > 0; - const hasContextZone = - hasQueuedMessage || hasTodos || params.allItemsLength > 0 || hasClarification; + const hasContextZone = hasQueuedMessage || hasTodos || params.allItemsLength > 0; const showFocusHint = !params.isInputFocused && !hasClarification && !hasPendingComments; const inputPlaceholder = getInputPlaceholder( params.placeholder, @@ -149,7 +150,7 @@ export function useChatInputContainer(params: UseChatInputContainerParams) { const [showNewSessionDialog, setShowNewSessionDialog] = useState(false); const [contextPopoverOpen, setContextPopoverOpen] = useState(false); - const { height, resetHeight, containerRef, resizeHandleProps } = useResizableInput( + const { height, resetHeight, autoExpand, containerRef, resizeHandleProps } = useResizableInput( sessionId ?? undefined, ); const { value, inputRef, handleImagePaste, handleChange, handleSubmit, allItems } = @@ -158,6 +159,7 @@ export function useChatInputContainer(params: UseChatInputContainerParams) { isSending, contextItems, pendingCommentsByFile, + hasContextComments: params.hasContextComments, showRequestChangesTooltip, onRequestChangesTooltipDismiss, onSubmit, @@ -165,6 +167,15 @@ export function useChatInputContainer(params: UseChatInputContainerParams) { useInputHandle(ref, inputRef); + // Auto-expand the input container as the user types more lines + const handleChangeWithAutoExpand = useCallback( + (val: string) => { + handleChange(val); + requestAnimationFrame(autoExpand); + }, + [handleChange, autoExpand], + ); + useEffect(() => { if (showRequestChangesTooltip && inputRef.current) inputRef.current.focus(); }, [showRequestChangesTooltip, inputRef]); @@ -212,7 +223,7 @@ export function useChatInputContainer(params: UseChatInputContainerParams) { value, inputRef, handleImagePaste, - handleChange, + handleChange: handleChangeWithAutoExpand, handleSubmitWithReset, handleAgentCommand, allItems, diff --git a/apps/web/components/task/chat/use-chat-input-state.ts b/apps/web/components/task/chat/use-chat-input-state.ts index 87bd24eac6..92d6d01435 100644 --- a/apps/web/components/task/chat/use-chat-input-state.ts +++ b/apps/web/components/task/chat/use-chat-input-state.ts @@ -25,6 +25,8 @@ type UseChatInputStateProps = { isSending: boolean; contextItems: ContextItem[]; pendingCommentsByFile?: Record; + /** Whether there are plan comments or PR feedback that allow empty-text submit */ + hasContextComments?: boolean; showRequestChangesTooltip: boolean; onRequestChangesTooltipDismiss?: () => void; onSubmit: ( @@ -112,6 +114,7 @@ export function useChatInputState({ isSending, contextItems, pendingCommentsByFile, + hasContextComments = false, showRequestChangesTooltip, onRequestChangesTooltipDismiss, onSubmit, @@ -163,7 +166,9 @@ export function useChatInputState({ const trimmed = valueRef.current.trim(); const allComments = collectComments(pendingCommentsRef.current); const currentAttachments = attachmentsRef.current; - if (!trimmed && allComments.length === 0 && currentAttachments.length === 0) return; + const hasContent = + trimmed || allComments.length > 0 || currentAttachments.length > 0 || hasContextComments; + if (!hasContent) return; const messageAttachments = toMessageAttachments(currentAttachments); const inlineMentions = inputRef.current?.getMentions() ?? []; onSubmit( @@ -179,7 +184,7 @@ export function useChatInputState({ resetHeight(); clearDraft(sessionId); }, - [onSubmit, isSending, sessionId, attachmentsRef, setAttachments], + [onSubmit, isSending, sessionId, attachmentsRef, setAttachments, hasContextComments], ); const allItems = useMemo((): ContextItem[] => { diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts index fa4cef2cc5..f8bf8395b3 100644 --- a/apps/web/components/task/chat/use-chat-panel-state.ts +++ b/apps/web/components/task/chat/use-chat-panel-state.ts @@ -1,13 +1,15 @@ "use client"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useAppStore } from "@/components/state-provider"; import { getLocalStorage } from "@/lib/local-storage"; import { useLayoutStore } from "@/lib/state/layout-store"; +import { useDockviewStore } from "@/lib/state/dockview-store"; import { usePanelActions } from "@/hooks/use-panel-actions"; import { useSessionMessages } from "@/hooks/domains/session/use-session-messages"; import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts"; import { useSessionState } from "@/hooks/domains/session/use-session-state"; +import { useSessionMcp } from "@/hooks/domains/session/use-session-mcp"; import { useProcessedMessages } from "@/hooks/use-processed-messages"; import { useSessionModel } from "@/hooks/domains/session/use-session-model"; import { useQueue } from "@/hooks/domains/session/use-queue"; @@ -44,54 +46,103 @@ export function usePlanMode(resolvedSessionId: string | null, taskId: string | n ? (state.documentPanel.activeDocumentBySessionId[resolvedSessionId] ?? null) : null, ); - const layoutBySession = useLayoutStore((state) => state.columnsBySessionId); const closeDocument = useLayoutStore((state) => state.closeDocument); const setActiveDocument = useAppStore((state) => state.setActiveDocument); const setPlanMode = useAppStore((state) => state.setPlanMode); const addContextFile = useContextFilesStore((s) => s.addFile); - const { addPlan } = usePanelActions(); + const removeContextFile = useContextFilesStore((s) => s.removeFile); + const applyBuiltInPreset = useDockviewStore((s) => s.applyBuiltInPreset); - const planModeEnabled = useMemo(() => { - if (!resolvedSessionId || !activeDocument || activeDocument.type !== "plan") return false; - const layout = layoutBySession[resolvedSessionId]; - return layout?.document === true; - }, [resolvedSessionId, activeDocument, layoutBySession]); + const planModeFromStore = useAppStore((state) => + resolvedSessionId ? (state.chatInput.planModeBySessionId[resolvedSessionId] ?? false) : false, + ); + + const planModeEnabled = planModeFromStore; + const planLayoutVisible = activeDocument?.type === "plan"; useEffect(() => { if (!resolvedSessionId) return; const stored = getLocalStorage(`plan-mode-${resolvedSessionId}`, false); if (stored) { setPlanMode(resolvedSessionId, true); - addContextFile(resolvedSessionId, { path: PLAN_CONTEXT_PATH, name: "Plan", pinned: true }); + addContextFile(resolvedSessionId, { path: PLAN_CONTEXT_PATH, name: "Plan" }); } }, [resolvedSessionId, setPlanMode, addContextFile]); - const handlePlanModeChange = useCallback( - (enabled: boolean) => { + // Re-focus chat input after dockview layout rebuild completes + const refocusChatAfterLayout = useCallback(() => { + const unsub = useDockviewStore.subscribe((state) => { + if (!state.isRestoringLayout) { + unsub(); + requestAnimationFrame(() => { + document.querySelector(".tiptap.ProseMirror")?.focus(); + }); + } + }); + }, []); + + // Toggle only the plan layout (plan panel + preset) without changing chat input plan mode state + const togglePlanLayout = useCallback( + (show: boolean) => { if (!resolvedSessionId || !taskId) return; - if (enabled) { + if (show) { setActiveDocument(resolvedSessionId, { type: "plan", taskId }); - addPlan(); - setPlanMode(resolvedSessionId, true); - addContextFile(resolvedSessionId, { path: PLAN_CONTEXT_PATH, name: "Plan", pinned: true }); + applyBuiltInPreset("plan"); } else { + applyBuiltInPreset("default"); closeDocument(resolvedSessionId); setActiveDocument(resolvedSessionId, null); - setPlanMode(resolvedSessionId, false); } + refocusChatAfterLayout(); }, [ resolvedSessionId, taskId, setActiveDocument, - addPlan, + applyBuiltInPreset, closeDocument, - setPlanMode, - addContextFile, + refocusChatAfterLayout, ], ); - return { planModeEnabled, activeDocument, handlePlanModeChange }; + // Full plan mode toggle: layout + chat input state (context file, border, submit style) + const handlePlanModeChange = useCallback( + (enabled: boolean) => { + if (!resolvedSessionId || !taskId) return; + if (enabled) { + setActiveDocument(resolvedSessionId, { type: "plan", taskId }); + applyBuiltInPreset("plan"); + setPlanMode(resolvedSessionId, true); + addContextFile(resolvedSessionId, { path: PLAN_CONTEXT_PATH, name: "Plan" }); + } else { + applyBuiltInPreset("default"); + closeDocument(resolvedSessionId); + setActiveDocument(resolvedSessionId, null); + setPlanMode(resolvedSessionId, false); + removeContextFile(resolvedSessionId, PLAN_CONTEXT_PATH); + } + refocusChatAfterLayout(); + }, + [ + resolvedSessionId, + taskId, + setActiveDocument, + applyBuiltInPreset, + closeDocument, + setPlanMode, + addContextFile, + removeContextFile, + refocusChatAfterLayout, + ], + ); + + return { + planModeEnabled, + planLayoutVisible, + activeDocument, + handlePlanModeChange, + togglePlanLayout, + }; } export function useContextFiles(resolvedSessionId: string | null) { @@ -320,6 +371,52 @@ export function useChatPanelState({ const sessionState = useSessionState(sessionId); const { resolvedSessionId, taskId } = sessionState; const planMode = usePlanMode(resolvedSessionId, taskId); + const { supportsMcp, mcpServers } = useSessionMcp(sessionState.session?.agent_profile_id); + const planModeAvailable = supportsMcp; + + // When MCP is available: full plan mode toggle (layout + chat input state) + // When MCP is unavailable: layout-only toggle (plan panel visible, but no plan context/border) + const { + handlePlanModeChange: rawHandlePlanModeChange, + togglePlanLayout, + planModeEnabled, + planLayoutVisible, + } = planMode; + const guardedHandlePlanModeChange = useCallback( + (enabled: boolean) => { + if (planModeAvailable) { + rawHandlePlanModeChange(enabled); + } else { + // Toggle based on current layout state, ignoring the passed value + togglePlanLayout(!planLayoutVisible); + } + }, + [planModeAvailable, rawHandlePlanModeChange, togglePlanLayout, planLayoutVisible], + ); + + // Auto-disable plan mode if agent doesn't support MCP (e.g. started from create dialog) + const hasAgentProfile = Boolean(sessionState.session?.agent_profile_id); + const hasAutoDisabled = useRef(false); + useEffect(() => { + if ( + planModeEnabled && + hasAgentProfile && + !planModeAvailable && + resolvedSessionId && + !hasAutoDisabled.current + ) { + hasAutoDisabled.current = true; + rawHandlePlanModeChange(false); + } + if (!planModeEnabled) hasAutoDisabled.current = false; + }, [ + planModeEnabled, + hasAgentProfile, + planModeAvailable, + resolvedSessionId, + rawHandlePlanModeChange, + ]); + const contextFilesState = useContextFiles(resolvedSessionId); const { contextFiles, removeContextFile, unpinFile } = contextFilesState; const sessionData = useSessionData( @@ -350,11 +447,14 @@ export function useChatPanelState({ return { ...sessionState, ...planMode, + handlePlanModeChange: guardedHandlePlanModeChange, ...contextFilesState, ...sessionData, ...comments, contextItems, planContextEnabled, + planModeAvailable, + mcpServers, prompts, }; } diff --git a/apps/web/components/task/chat/use-tiptap-editor.ts b/apps/web/components/task/chat/use-tiptap-editor.ts index d43e70f881..d898287751 100644 --- a/apps/web/components/task/chat/use-tiptap-editor.ts +++ b/apps/web/components/task/chat/use-tiptap-editor.ts @@ -41,6 +41,7 @@ type UseTipTapEditorOptions = { disabled: boolean; className?: string; planModeEnabled: boolean; + onPlanModeChange?: (enabled: boolean) => void; submitKey: "enter" | "cmd_enter"; onFocus?: () => void; onBlur?: () => void; @@ -60,6 +61,8 @@ function useTipTapRefs(opts: UseTipTapEditorOptions) { const onChangeRef = useRef(opts.onChange); const onImagePasteRef = useRef(opts.onImagePaste); const sessionIdRef = useRef(opts.sessionId); + const planModeEnabledRef = useRef(opts.planModeEnabled); + const onPlanModeChangeRef = useRef(opts.onPlanModeChange); useLayoutEffect(() => { onSubmitRef.current = opts.onSubmit; submitKeyRef.current = opts.submitKey; @@ -67,8 +70,19 @@ function useTipTapRefs(opts: UseTipTapEditorOptions) { onChangeRef.current = opts.onChange; onImagePasteRef.current = opts.onImagePaste; sessionIdRef.current = opts.sessionId; + planModeEnabledRef.current = opts.planModeEnabled; + onPlanModeChangeRef.current = opts.onPlanModeChange; }); - return { onSubmitRef, submitKeyRef, disabledRef, onChangeRef, onImagePasteRef, sessionIdRef }; + return { + onSubmitRef, + submitKeyRef, + disabledRef, + onChangeRef, + onImagePasteRef, + sessionIdRef, + planModeEnabledRef, + onPlanModeChangeRef, + }; } export function useTipTapEditor(opts: UseTipTapEditorOptions) { @@ -87,7 +101,13 @@ export function useTipTapEditor(opts: UseTipTapEditorOptions) { ref, } = opts; const refs = useTipTapRefs(opts); - const SubmitKeymap = useSubmitKeymap(refs.disabledRef, refs.submitKeyRef, refs.onSubmitRef); + const SubmitKeymap = useSubmitKeymap( + refs.disabledRef, + refs.submitKeyRef, + refs.onSubmitRef, + refs.planModeEnabledRef, + refs.onPlanModeChangeRef, + ); const isSyncingRef = useRef(false); const initialSyncDoneRef = useRef(false); const editor = useEditor({ @@ -270,6 +290,8 @@ function useSubmitKeymap( disabledRef: React.RefObject, submitKeyRef: React.RefObject<"enter" | "cmd_enter">, onSubmitRef: React.RefObject<(() => void) | undefined>, + planModeEnabledRef: React.RefObject, + onPlanModeChangeRef: React.RefObject<((enabled: boolean) => void) | undefined>, ) { return useMemo(() => { return Extension.create({ @@ -292,6 +314,11 @@ function useSubmitKeymap( } return false; }, + "Shift-Tab": () => { + console.log("[ShiftTab] toggling plan mode", { current: planModeEnabledRef.current }); + onPlanModeChangeRef.current?.(!planModeEnabledRef.current); + return true; + }, }; }, }); diff --git a/apps/web/components/task/chat/virtualized-message-list.tsx b/apps/web/components/task/chat/virtualized-message-list.tsx index f6995613a6..8aa0cab7b5 100644 --- a/apps/web/components/task/chat/virtualized-message-list.tsx +++ b/apps/web/components/task/chat/virtualized-message-list.tsx @@ -369,7 +369,7 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({ }, [isRunning]); return ( - + void; @@ -52,6 +53,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ childrenByParentToolCallId, agentMessageCount, cancelQueue, + pendingClarification, } = panelState; const { handleCancelTurn, handleCancelQueue, handleQueueEditComplete } = useChatPanelHandlers( resolvedSessionId, @@ -82,6 +84,14 @@ export const TaskChatPanel = memo(function TaskChatPanel({ onOpenFile={onOpenFile} /> + {pendingClarification && !isArchived && ( +
+ +
+ )} {isArchived ? (
This task is archived and read-only. diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 0fb131b85e..d6ecc67342 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -99,7 +99,6 @@ const TaskTopBar = memo(function TaskTopBar({ displayBranch={displayBranch} repositoryPath={repositoryPath} worktreePath={worktreePath} - gitStatus={git} isRemoteExecutor={isRemoteExecutor} remoteExecutorName={remoteExecutorName} remoteExecutorType={remoteExecutorType} @@ -120,6 +119,7 @@ const TaskTopBar = memo(function TaskTopBar({ - void; isArchived?: boolean; @@ -424,13 +423,14 @@ function TopBarRight({ )} + {!isArchived && ( <> - - + + )} diff --git a/apps/web/hooks/domains/session/use-session-mcp.ts b/apps/web/hooks/domains/session/use-session-mcp.ts new file mode 100644 index 0000000000..463e0df494 --- /dev/null +++ b/apps/web/hooks/domains/session/use-session-mcp.ts @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useAppStore } from "@/components/state-provider"; +import { getAgentProfileMcpConfigAction } from "@/app/actions/agents"; + +const EMPTY_SERVERS: string[] = []; +const DEFAULT_KANDEV: string[] = ["kandev"]; + +/** + * Resolves MCP support and configured MCP server names for the current session's agent. + * Returns whether the agent supports MCP and the list of active MCP server names. + */ +export function useSessionMcp(agentProfileId: string | null | undefined) { + const settingsAgents = useAppStore((state) => state.settingsAgents.items); + // Track which profileId the fetched servers belong to, so stale results are ignored + const [fetchResult, setFetchResult] = useState<{ + profileId: string; + servers: string[]; + } | null>(null); + + const agent = useMemo(() => { + if (!agentProfileId) return null; + for (const a of settingsAgents) { + if (a.profiles.some((p) => p.id === agentProfileId)) return a; + } + return null; + }, [agentProfileId, settingsAgents]); + + const supportsMcp = agent?.supports_mcp ?? false; + + useEffect(() => { + if (!agentProfileId || !supportsMcp) return; + let active = true; + const currentProfileId = agentProfileId; + getAgentProfileMcpConfigAction(currentProfileId) + .then((config) => { + if (!active) return; + const userServers = config.enabled ? Object.keys(config.servers) : []; + setFetchResult({ profileId: currentProfileId, servers: ["kandev", ...userServers] }); + }) + .catch(() => { + if (!active) return; + setFetchResult({ profileId: currentProfileId, servers: DEFAULT_KANDEV }); + }); + return () => { + active = false; + }; + }, [agentProfileId, supportsMcp]); + + const mcpServers = useMemo(() => { + if (!supportsMcp) return EMPTY_SERVERS; + if (fetchResult && fetchResult.profileId === agentProfileId) return fetchResult.servers; + return DEFAULT_KANDEV; + }, [supportsMcp, fetchResult, agentProfileId]); + + return { supportsMcp, mcpServers }; +} diff --git a/apps/web/hooks/use-message-handler.ts b/apps/web/hooks/use-message-handler.ts index 9feda48a1e..4499eaf6ec 100644 --- a/apps/web/hooks/use-message-handler.ts +++ b/apps/web/hooks/use-message-handler.ts @@ -9,11 +9,14 @@ import type { CustomPrompt } from "@/lib/types/http"; function buildDocumentContext( activeDocument: ActiveDocument | null, + planModeEnabled: boolean, planComments?: PlanComment[], ): string { if (!activeDocument) return ""; if (activeDocument.type === "plan") { + if (!planModeEnabled) return ""; + let context = `\n\n\nACTIVE DOCUMENT: The user is editing the task plan side-by-side with this chat.\nRead the current plan using the plan_get MCP tool to understand the context before responding.\nAny plan modifications should use the plan_update MCP tool.`; if (planComments && planComments.length > 0) { @@ -66,7 +69,7 @@ export interface UseMessageHandlerParams { taskId: string | null; sessionModel: string | null; activeModel: string | null; - planMode?: boolean; + planModeEnabled?: boolean; isAgentBusy?: boolean; activeDocument?: ActiveDocument | null; planComments?: PlanComment[]; @@ -122,7 +125,7 @@ export function useMessageHandler({ taskId, sessionModel, activeModel, - planMode = false, + planModeEnabled = false, isAgentBusy = false, activeDocument = null, planComments = [], @@ -134,14 +137,14 @@ export function useMessageHandler({ const buildFinalMessage = useCallback( (message: string, inlineMentions?: ContextFile[]) => { const allContextFiles = [...contextFiles, ...(inlineMentions || [])]; - const documentContext = buildDocumentContext(activeDocument, planComments); + const documentContext = buildDocumentContext(activeDocument, planModeEnabled, planComments); const contextFilesContext = buildContextFilesContext(allContextFiles, prompts); return { finalMessage: message.trim() + documentContext + contextFilesContext, allContextFiles, }; }, - [contextFiles, activeDocument, planComments, prompts], + [contextFiles, activeDocument, planModeEnabled, planComments, prompts], ); const handleSendMessage = useCallback( @@ -170,7 +173,7 @@ export function useMessageHandler({ data: att.data, mime_type: att.mime_type, })); - await queue(taskId, finalMessage, modelToSend, planMode, queueAttachments); + await queue(taskId, finalMessage, modelToSend, planModeEnabled, queueAttachments); return; } @@ -179,7 +182,7 @@ export function useMessageHandler({ resolvedSessionId, finalMessage, modelToSend, - planMode, + planMode: planModeEnabled, hasReviewComments, attachments, contextFilesMeta, @@ -190,7 +193,7 @@ export function useMessageHandler({ taskId, activeModel, sessionModel, - planMode, + planModeEnabled, isAgentBusy, queue, buildFinalMessage, diff --git a/apps/web/hooks/use-resizable-input.ts b/apps/web/hooks/use-resizable-input.ts index 127ac26005..f2e110ce3e 100644 --- a/apps/web/hooks/use-resizable-input.ts +++ b/apps/web/hooks/use-resizable-input.ts @@ -100,9 +100,22 @@ export function useResizableInput(sessionId: string | undefined) { }; }, [persistHeight]); + /** Expand the input to fit content, clamped to max height. Does not shrink below user-set height. */ + const autoExpand = useCallback(() => { + const el = containerRef.current; + if (!el || isDragging.current) return; + const maxHeight = Math.min(window.innerHeight * 0.6, MAX_ABSOLUTE); + if (el.scrollHeight > height) { + const next = Math.min(el.scrollHeight, maxHeight); + setHeight(next); + persistHeight(next); + } + }, [height, persistHeight]); + return { height, resetHeight, + autoExpand, containerRef, resizeHandleProps: { onMouseDown: handleMouseDown, diff --git a/apps/web/lib/keyboard/constants.ts b/apps/web/lib/keyboard/constants.ts index 8298e070c9..f7a41a9834 100644 --- a/apps/web/lib/keyboard/constants.ts +++ b/apps/web/lib/keyboard/constants.ts @@ -129,4 +129,8 @@ export const SHORTCUTS = { key: KEYS.P, modifiers: { ctrlOrCmd: true, shift: true }, }, + TOGGLE_PLAN_MODE: { + key: KEYS.TAB, + modifiers: { shift: true }, + }, } as const; diff --git a/apps/web/lib/settings/constants.ts b/apps/web/lib/settings/constants.ts index a5dc016292..1521df8f97 100644 --- a/apps/web/lib/settings/constants.ts +++ b/apps/web/lib/settings/constants.ts @@ -4,6 +4,7 @@ export const STORAGE_KEYS = { LAST_AGENT_PROFILE_ID: "kandev.dialog.lastAgentProfileId", LAST_REPOSITORY_ID: "kandev.dialog.lastRepositoryId", LAST_BRANCH: "kandev.dialog.lastBranch", + LAST_EXECUTOR_PROFILE_ID: "kandev.dialog.lastExecutorProfileId", } as const; export const DEFAULT_BACKEND_URL = "http://localhost:8080"; diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index 8a2941283e..c7b3b5d213 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -584,15 +584,28 @@ export const useDockviewStore = create((set, get) => ({ // Clear pinned overrides so fresh default gets clean preset widths const freshPinned = new Map(); set({ isRestoringLayout: true, pinnedWidths: freshPinned }); - const state = userDefaultLayout ?? getPresetLayout("default"); - const ids = applyLayout(api, state, freshPinned); - const hasSidebar = state.columns.some((c) => c.id === "sidebar"); - const hasRight = state.columns.length > (hasSidebar ? 2 : 1); - set({ ...ids, sidebarVisible: hasSidebar, rightPanelsVisible: hasRight }); + // Check if plan mode was queued before navigation (e.g. task created in plan mode). + // If so, use the plan preset directly and consume the deferred actions. const pending = get().deferredPanelActions; - if (pending.length > 0) { + const hasPlanAction = pending.some((a) => a.id === "plan"); + if (hasPlanAction) { set({ deferredPanelActions: [] }); - applyDeferredPanelActions(api, pending); + const planState = getPresetLayout("plan"); + const ids = applyLayout(api, planState, freshPinned); + set({ ...ids, sidebarVisible: true, rightPanelsVisible: false }); + // Apply any remaining non-plan deferred actions + const remaining = pending.filter((a) => a.id !== "plan"); + if (remaining.length > 0) applyDeferredPanelActions(api, remaining); + } else { + const state = userDefaultLayout ?? getPresetLayout("default"); + const ids = applyLayout(api, state, freshPinned); + const hasSidebar = state.columns.some((c) => c.id === "sidebar"); + const hasRight = state.columns.length > (hasSidebar ? 2 : 1); + set({ ...ids, sidebarVisible: hasSidebar, rightPanelsVisible: hasRight }); + if (pending.length > 0) { + set({ deferredPanelActions: [] }); + applyDeferredPanelActions(api, pending); + } } requestAnimationFrame(() => { syncPinnedWidthsFromApi(api, set); diff --git a/apps/web/lib/state/editor-resolver-store.ts b/apps/web/lib/state/editor-resolver-store.ts index e1fa35a692..e51e73646b 100644 --- a/apps/web/lib/state/editor-resolver-store.ts +++ b/apps/web/lib/state/editor-resolver-store.ts @@ -8,7 +8,7 @@ export type EditorContext = | "chat-diff" | "plan-editor"; -export type EditorProvider = "monaco" | "codemirror" | "pierre-diffs" | "tiptap"; +export type EditorProvider = "monaco" | "codemirror" | "shiki" | "pierre-diffs" | "tiptap"; const MONACO: EditorProvider = "monaco"; const PIERRE_DIFFS: EditorProvider = "pierre-diffs"; @@ -16,7 +16,7 @@ const PIERRE_DIFFS: EditorProvider = "pierre-diffs"; const VALID_PROVIDERS: Record = { "code-editor": [MONACO, "codemirror"], "diff-viewer": [MONACO, PIERRE_DIFFS], - "chat-code-block": [MONACO, "codemirror"], + "chat-code-block": ["shiki", MONACO, "codemirror"], "chat-diff": [MONACO, PIERRE_DIFFS], "plan-editor": ["tiptap"], }; @@ -24,7 +24,7 @@ const VALID_PROVIDERS: Record = { const DEFAULT_PROVIDERS: Record = { "code-editor": MONACO, "diff-viewer": PIERRE_DIFFS, - "chat-code-block": MONACO, + "chat-code-block": "shiki", "chat-diff": PIERRE_DIFFS, "plan-editor": "tiptap", }; @@ -51,12 +51,17 @@ export const useEditorResolverStore = create()( }), { name: "kandev-editor-providers", - version: 1, + version: 3, migrate: (persisted) => { const state = persisted as { providers: Record }; return { ...state, - providers: { ...state.providers, "diff-viewer": PIERRE_DIFFS, "chat-diff": PIERRE_DIFFS }, + providers: { + ...state.providers, + "diff-viewer": PIERRE_DIFFS, + "chat-diff": PIERRE_DIFFS, + "chat-code-block": "shiki", + }, }; }, }, diff --git a/apps/web/lib/state/layout-manager/presets.ts b/apps/web/lib/state/layout-manager/presets.ts index 78c5ad1e7e..259b18cc4e 100644 --- a/apps/web/lib/state/layout-manager/presets.ts +++ b/apps/web/lib/state/layout-manager/presets.ts @@ -19,7 +19,7 @@ export function defaultLayout(): LayoutState { pinned: true, maxWidth: LAYOUT_SIDEBAR_MAX_PX, groups: [ - { panels: [panel("changes"), panel("files")] }, + { panels: [panel("files"), panel("changes")] }, { panels: [panel("terminal-default")] }, ], }, diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 9387e868de..4d7595177a 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -183,6 +183,7 @@ export type MessageAddedPayload = { author_type: "user" | "agent"; author_id?: string; content: string; + raw_content?: string; type?: string; metadata?: Record; requests_input?: boolean; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index f3343d3cf0..ca57f5361d 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -563,6 +563,7 @@ export type Message = { author_type: MessageAuthorType; author_id?: string; content: string; + raw_content?: string; type: MessageType; metadata?: Record; requests_input?: boolean; diff --git a/apps/web/lib/ws/handlers/messages.ts b/apps/web/lib/ws/handlers/messages.ts index 2916e403f4..561382ebfe 100644 --- a/apps/web/lib/ws/handlers/messages.ts +++ b/apps/web/lib/ws/handlers/messages.ts @@ -18,6 +18,7 @@ export function registerMessagesHandlers(store: StoreApi): WsHandlers author_type: payload.author_type, author_id: payload.author_id, content: payload.content, + raw_content: payload.raw_content, type: (payload.type as MessageType) || "message", metadata: payload.metadata, requests_input: payload.requests_input, @@ -37,6 +38,7 @@ export function registerMessagesHandlers(store: StoreApi): WsHandlers author_type: payload.author_type, author_id: payload.author_id, content: payload.content, + raw_content: payload.raw_content, type: (payload.type as MessageType) || "message", metadata: payload.metadata, requests_input: payload.requests_input, diff --git a/apps/web/package.json b/apps/web/package.json index 961213b723..a070a9a0c2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -97,6 +97,7 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "shadcn": "^3.6.3", + "shiki": "^3.22.0", "sonner": "^2.0.7", "styled-jsx": "5.1.6", "tailwind-merge": "^3.4.0",