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) {
+
+ 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 (
);
@@ -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 ? (
- {isSending ? (
-
- ) : (
-
- )}
+ {isSending && }
+ {!isSending && planModeEnabled && }
+ {!isSending && !planModeEnabled && }
)}
);
}
+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 (
+
+
+ onPlanModeChange(!planModeEnabled)}
+ >
+
+
+
+ {tooltip}
+
+ );
+}
+
+function ImplementPlanButton({ onClick }: { onClick: () => void }) {
+ return (
+
+
+
+
+ Implement
+
+
+ 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 (
-
-
- onPlanModeChange(!planModeEnabled)}
- >
-
-
-
- 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 (
-
+
+ {hasHiddenPrompts && }
);
}
@@ -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",