feat: several UX improvements (#272)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,7 +25,7 @@ var systemTagRegex = regexp.MustCompile(`<kandev-system>[\s\S]*?</kandev-system>
|
||||
// StripSystemContent removes all <kandev-system>...</kandev-system> 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 <kandev-system> 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 <kandev-system> 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
+50
-14
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"border-transparent px-2 text-xs font-semibold rounded-lg",
|
||||
commitTone[tone],
|
||||
className,
|
||||
)}
|
||||
variant="outline"
|
||||
className={cn("h-6 px-1.5 text-xs font-medium rounded-md", commitTone[tone], className)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function CodeMirrorCodeBlock({ children, className }: CodeBlockProps) {
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"absolute top-2 right-2 z-10",
|
||||
"absolute top-1 right-2 z-10",
|
||||
"p-1.5 rounded-md",
|
||||
"bg-white/10 hover:bg-white/20",
|
||||
"transition-all duration-200",
|
||||
|
||||
@@ -32,7 +32,7 @@ export function MonacoCodeBlock({ children, className }: MonacoCodeBlockProps) {
|
||||
<button
|
||||
onClick={() => copy(code)}
|
||||
className={cn(
|
||||
"absolute top-2 right-2 z-10",
|
||||
"absolute top-1 right-2 z-10",
|
||||
"p-1.5 rounded-md",
|
||||
"bg-white/10 hover:bg-white/20",
|
||||
"transition-all duration-200",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
|
||||
import { highlightCode } from "./shiki-highlighter";
|
||||
|
||||
type ShikiCodeBlockProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ShikiCodeBlock({ children, className }: ShikiCodeBlockProps) {
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const { theme, systemTheme } = useTheme();
|
||||
const effectiveTheme = theme === "system" ? systemTheme : theme;
|
||||
const isDark = effectiveTheme === "dark";
|
||||
|
||||
const code = String(children).replace(/\n$/, "");
|
||||
const lang = className?.replace("language-", "").toLowerCase() ?? "";
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const prevKeyRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
const key = `${lang}:${isDark}:${code}`;
|
||||
if (key === prevKeyRef.current) return;
|
||||
prevKeyRef.current = key;
|
||||
highlightCode(code, lang, isDark).then(setHtml);
|
||||
}, [code, lang, isDark]);
|
||||
|
||||
return (
|
||||
<div className="relative group/code-block my-4 w-fit max-w-full min-w-[50%]">
|
||||
<button
|
||||
onClick={() => copy(code)}
|
||||
className={cn(
|
||||
"absolute top-1 right-2 z-10",
|
||||
"p-1.5 rounded-md",
|
||||
"bg-white/10 hover:bg-white/20",
|
||||
"transition-all duration-200",
|
||||
"opacity-0 group-hover/code-block:opacity-100",
|
||||
"cursor-pointer",
|
||||
)}
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck className="h-3 w-3 text-green-400" />
|
||||
) : (
|
||||
<IconCopy className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{html ? (
|
||||
<div
|
||||
className="shiki-code-block rounded-md overflow-hidden text-xs"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<pre className="rounded-md overflow-hidden text-xs bg-background p-2 text-foreground">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createHighlighter, type Highlighter } from "shiki";
|
||||
let highlighterPromise: Promise<Highlighter> | null = null;
|
||||
const loadedLanguages = new Set<string>();
|
||||
|
||||
const DARK_THEME = "dark-plus";
|
||||
const LIGHT_THEME = "light-plus";
|
||||
|
||||
function getHighlighter(): Promise<Highlighter> {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighter({
|
||||
themes: [DARK_THEME, LIGHT_THEME],
|
||||
langs: [],
|
||||
});
|
||||
}
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
async function ensureLanguage(highlighter: Highlighter, lang: string): Promise<boolean> {
|
||||
if (!lang || loadedLanguages.has(lang)) return loadedLanguages.has(lang);
|
||||
try {
|
||||
await highlighter.loadLanguage(lang as Parameters<Highlighter["loadLanguage"]>[0]);
|
||||
loadedLanguages.add(lang);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function highlightCode(code: string, lang: string, isDark: boolean): Promise<string> {
|
||||
const highlighter = await getHighlighter();
|
||||
const hasLang = await ensureLanguage(highlighter, lang);
|
||||
return highlighter.codeToHtml(code, {
|
||||
lang: hasLang ? lang : "text",
|
||||
theme: isDark ? DARK_THEME : LIGHT_THEME,
|
||||
});
|
||||
}
|
||||
@@ -324,7 +324,6 @@ export function KanbanCard({
|
||||
style={style}
|
||||
className={cn(
|
||||
"max-h-48 bg-card rounded-sm data-[size=sm]:py-1 cursor-pointer mb-2 w-full py-0 relative border border-border overflow-visible shadow-none ring-0",
|
||||
(task.state === "IN_PROGRESS" || task.state === "SCHEDULING") && "kanban-task-pulse",
|
||||
needsAction(task) && "border-l-2 border-l-amber-500",
|
||||
isDragging && "opacity-50 z-50",
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
IconGitCommit,
|
||||
IconTerminal2,
|
||||
IconArrowDown,
|
||||
IconCloud,
|
||||
} from "@tabler/icons-react";
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@kandev/ui/collapsible";
|
||||
import { Kbd } from "@kandev/ui/kbd";
|
||||
@@ -65,12 +66,18 @@ const RUNTIMES = [
|
||||
description: "Containerized execution for full isolation and reproducibility.",
|
||||
icon: IconBrandDocker,
|
||||
},
|
||||
{
|
||||
name: "Sprites (Remote sprites.dev)",
|
||||
description: "Hardware-isolated execution environment for arbitrary code.",
|
||||
icon: IconCloud,
|
||||
href: "https://sprites.dev",
|
||||
},
|
||||
];
|
||||
|
||||
const STEP_TITLES = ["AI Agents", "Environments", "Agentic Workflows", "Command Panel"];
|
||||
const STEP_TITLES = ["AI Agents", "Executors", "Agentic Workflows", "Command Panel"];
|
||||
const STEP_DESCRIPTIONS = [
|
||||
"These AI coding agents were discovered on your system.",
|
||||
"Agents can run in different runtime environments.",
|
||||
"Agents can run in different executor environments — local, containerized, or remote.",
|
||||
"Workflows define the steps and automation for your tasks.",
|
||||
"Quick access to actions from anywhere with a keyboard shortcut.",
|
||||
];
|
||||
@@ -374,6 +381,10 @@ function StepAgents({
|
||||
Expand an agent to configure its model and permissions. Changes are saved when you proceed.
|
||||
You can also add custom TUI agents later in Settings > Agents.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="text-yellow-500 font-medium">Careful:</span> The default Agent Profiles run
|
||||
with with Auto Approve enabled (YOLO mode).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -384,13 +395,25 @@ function StepEnvironments() {
|
||||
<div className="grid gap-2">
|
||||
{RUNTIMES.map((runtime) => {
|
||||
const Icon = runtime.icon;
|
||||
const nameEl = runtime.href ? (
|
||||
<a
|
||||
href={runtime.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium hover:underline cursor-pointer"
|
||||
>
|
||||
{runtime.name}
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-sm font-medium">{runtime.name}</p>
|
||||
);
|
||||
return (
|
||||
<div key={runtime.name} className="flex items-start gap-3 rounded-lg border p-3">
|
||||
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<Icon className="h-4.5 w-4.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{runtime.name}</p>
|
||||
{nameEl}
|
||||
<p className="text-xs text-muted-foreground">{runtime.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -398,7 +421,7 @@ function StepEnvironments() {
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure runtime environments in Settings to control where agents execute.
|
||||
Configure executors in Settings to control where agents execute.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<string | null>(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<typeof useToast>["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<typeof useToast>["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<string | null>(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<string | null>(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 });
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden sm:h-7 h-10">
|
||||
@@ -80,6 +82,15 @@ function StartTaskSplitButton({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-auto min-w-max">
|
||||
{onPlanModeAction && (
|
||||
<DropdownMenuItem
|
||||
onClick={onPlanModeAction}
|
||||
className="cursor-pointer whitespace-nowrap focus:bg-muted/80 hover:bg-muted/80"
|
||||
>
|
||||
<IconFileInvoice className="h-3.5 w-3.5 mr-1.5" />
|
||||
Start task in plan mode
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={onAltAction}
|
||||
className="cursor-pointer whitespace-nowrap focus:bg-muted/80 hover:bg-muted/80"
|
||||
@@ -173,6 +184,7 @@ export type TaskCreateDialogFooterProps = {
|
||||
onCancel: () => 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof createTask>[0];
|
||||
|
||||
export type { CreateTaskParams };
|
||||
|
||||
export function autoSelectBranch(branchList: Branch[], setBranch: (value: string) => void): void {
|
||||
const lastUsedBranch = getLocalStorage<string | null>(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<typeof useRouter>;
|
||||
};
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<StepType[] | null>(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<string | null>(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<typeof useToast>["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<typeof useToast>["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<string | null>(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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<typeof createTask>[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<typeof useRouter>;
|
||||
};
|
||||
|
||||
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<OrchestratorStartResponse>(
|
||||
"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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full rounded-none sm:w-[900px] sm:h-auto sm:max-w-none sm:max-h-[85vh] sm:rounded-lg flex flex-col">
|
||||
@@ -470,6 +457,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
|
||||
onCancel={handleCancel}
|
||||
onUpdateWithoutAgent={handleUpdateWithoutAgent}
|
||||
onCreateWithoutAgent={handleCreateWithoutAgent}
|
||||
onCreateWithPlanMode={handleCreateWithPlanMode}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -15,15 +15,43 @@ import { type QueuedMessageIndicatorHandle } from "@/components/task/chat/queued
|
||||
import {
|
||||
formatReviewCommentsAsMarkdown,
|
||||
formatPRFeedbackAsMarkdown,
|
||||
formatPlanCommentsAsMarkdown,
|
||||
} from "@/lib/state/slices/comments/format";
|
||||
import type { DiffComment } from "@/lib/diff/types";
|
||||
import type { useChatPanelState } from "./use-chat-panel-state";
|
||||
|
||||
const PLAN_CONTEXT_PATH = "plan:context";
|
||||
|
||||
function buildSubmitMessage(
|
||||
message: string,
|
||||
reviewComments: DiffComment[] | undefined,
|
||||
pendingPRFeedback: import("@/lib/state/slices/comments").PRFeedbackComment[],
|
||||
planComments: import("@/lib/state/slices/comments").PlanComment[],
|
||||
): string {
|
||||
let finalMessage = message;
|
||||
if (reviewComments && reviewComments.length > 0) {
|
||||
finalMessage = formatReviewCommentsAsMarkdown(reviewComments) + (message || "");
|
||||
}
|
||||
if (pendingPRFeedback.length > 0) {
|
||||
finalMessage = formatPRFeedbackAsMarkdown(pendingPRFeedback) + finalMessage;
|
||||
}
|
||||
if (planComments.length > 0) {
|
||||
const planMarkdown = formatPlanCommentsAsMarkdown(planComments);
|
||||
const header = "### Plan Comments\n\n";
|
||||
finalMessage = finalMessage
|
||||
? `${header}${planMarkdown}\n\n---\n\n${finalMessage}`
|
||||
: `${header}${planMarkdown}`;
|
||||
}
|
||||
return finalMessage;
|
||||
}
|
||||
|
||||
function resolveInputPlaceholder(
|
||||
isAgentBusy: boolean,
|
||||
activeDocumentType: string | undefined,
|
||||
planModeEnabled: boolean,
|
||||
hasClarification: boolean,
|
||||
): string {
|
||||
if (hasClarification) return "Answer the question above to continue...";
|
||||
if (isAgentBusy) return "Queue instructions to the agent...";
|
||||
if (activeDocumentType === "file") return "Continue working on the file...";
|
||||
if (planModeEnabled) return "Continue working on the plan...";
|
||||
@@ -39,7 +67,6 @@ export function useSubmitHandler(
|
||||
resolvedSessionId,
|
||||
sessionModel,
|
||||
activeModel,
|
||||
planContextEnabled,
|
||||
isAgentBusy,
|
||||
activeDocument,
|
||||
planComments,
|
||||
@@ -50,13 +77,15 @@ export function useSubmitHandler(
|
||||
clearSessionPlanComments,
|
||||
handleClearPRFeedback,
|
||||
clearEphemeral,
|
||||
addContextFile,
|
||||
planModeEnabled,
|
||||
} = panelState;
|
||||
const { handleSendMessage } = useMessageHandler({
|
||||
resolvedSessionId,
|
||||
taskId: panelState.taskId,
|
||||
sessionModel,
|
||||
activeModel,
|
||||
planMode: planContextEnabled,
|
||||
planModeEnabled: panelState.planModeEnabled,
|
||||
isAgentBusy,
|
||||
activeDocument,
|
||||
planComments,
|
||||
@@ -74,15 +103,12 @@ export function useSubmitHandler(
|
||||
if (isSending) return;
|
||||
setIsSending(true);
|
||||
try {
|
||||
let finalMessage = message;
|
||||
if (reviewComments && reviewComments.length > 0) {
|
||||
const reviewMarkdown = formatReviewCommentsAsMarkdown(reviewComments);
|
||||
finalMessage = reviewMarkdown + (message ? message : "");
|
||||
}
|
||||
if (pendingPRFeedback.length > 0) {
|
||||
const prMarkdown = formatPRFeedbackAsMarkdown(pendingPRFeedback);
|
||||
finalMessage = prMarkdown + finalMessage;
|
||||
}
|
||||
const finalMessage = buildSubmitMessage(
|
||||
message,
|
||||
reviewComments,
|
||||
pendingPRFeedback,
|
||||
planComments,
|
||||
);
|
||||
const hasReviewComments = !!(reviewComments && reviewComments.length > 0);
|
||||
if (onSend) {
|
||||
await onSend(finalMessage);
|
||||
@@ -93,7 +119,13 @@ export function useSubmitHandler(
|
||||
markCommentsSent(reviewComments.map((c) => c.id));
|
||||
if (pendingPRFeedback.length > 0) handleClearPRFeedback();
|
||||
if (planComments.length > 0) clearSessionPlanComments();
|
||||
if (resolvedSessionId) clearEphemeral(resolvedSessionId);
|
||||
if (resolvedSessionId) {
|
||||
clearEphemeral(resolvedSessionId);
|
||||
// Re-add plan context if plan mode is still active (clearEphemeral removes unpinned files)
|
||||
if (planModeEnabled) {
|
||||
addContextFile(resolvedSessionId, { path: PLAN_CONTEXT_PATH, name: "Plan" });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
@@ -103,12 +135,14 @@ export function useSubmitHandler(
|
||||
onSend,
|
||||
handleSendMessage,
|
||||
markCommentsSent,
|
||||
planComments.length,
|
||||
planComments,
|
||||
clearSessionPlanComments,
|
||||
pendingPRFeedback,
|
||||
handleClearPRFeedback,
|
||||
resolvedSessionId,
|
||||
clearEphemeral,
|
||||
planModeEnabled,
|
||||
addContextFile,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -167,6 +201,46 @@ export function useChatPanelHandlers(
|
||||
return { handleCancelTurn, handleCancelQueue, handleQueueEditComplete };
|
||||
}
|
||||
|
||||
function useImplementPlan(
|
||||
resolvedSessionId: string | null,
|
||||
taskId: string | null,
|
||||
handlePlanModeChange: (enabled: boolean) => void,
|
||||
chatInputRef: React.RefObject<ChatInputContainerHandle | null>,
|
||||
) {
|
||||
return useCallback(() => {
|
||||
if (!resolvedSessionId || !taskId) return;
|
||||
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
|
||||
const userText = chatInputRef.current?.getValue() ?? "";
|
||||
chatInputRef.current?.clear();
|
||||
|
||||
const visibleText = userText.trim() || "Implement the plan";
|
||||
const content = `${visibleText}\n\n<kandev-system>
|
||||
IMPLEMENT PLAN: The user has approved the plan and wants you to implement it now.
|
||||
Read the current plan using the get_task_plan_kandev MCP tool.
|
||||
Implement all changes described in the plan step by step.
|
||||
After completing the implementation, provide a summary of what was done.
|
||||
</kandev-system>`;
|
||||
|
||||
client
|
||||
.request(
|
||||
"message.add",
|
||||
{
|
||||
task_id: taskId,
|
||||
session_id: resolvedSessionId,
|
||||
content,
|
||||
plan_mode: false,
|
||||
},
|
||||
10000,
|
||||
)
|
||||
.catch((err: unknown) => console.error("Failed to send implement plan message:", err));
|
||||
|
||||
handlePlanModeChange(false);
|
||||
}, [resolvedSessionId, taskId, handlePlanModeChange, chatInputRef]);
|
||||
}
|
||||
|
||||
type ChatInputAreaProps = {
|
||||
chatInputRef: React.RefObject<ChatInputContainerHandle | null>;
|
||||
queuedMessageRef: React.RefObject<QueuedMessageIndicatorHandle | null>;
|
||||
@@ -227,7 +301,20 @@ export function ChatInputArea({
|
||||
planContextEnabled,
|
||||
todoItems,
|
||||
} = panelState;
|
||||
const placeholder = resolveInputPlaceholder(isAgentBusy, activeDocument?.type, planModeEnabled);
|
||||
const handleImplementPlan = useImplementPlan(
|
||||
resolvedSessionId,
|
||||
taskId,
|
||||
handlePlanModeChange,
|
||||
chatInputRef,
|
||||
);
|
||||
|
||||
const hasClarification = !!panelState.pendingClarification;
|
||||
const placeholder = resolveInputPlaceholder(
|
||||
isAgentBusy,
|
||||
activeDocument?.type,
|
||||
planModeEnabled,
|
||||
hasClarification,
|
||||
);
|
||||
return (
|
||||
<div className="bg-card flex-shrink-0 px-2 pb-2 pt-1">
|
||||
<ChatInputContainer
|
||||
@@ -239,6 +326,8 @@ export function ChatInputArea({
|
||||
taskTitle={task?.title}
|
||||
taskDescription={taskDescription ?? ""}
|
||||
planModeEnabled={planModeEnabled}
|
||||
planModeAvailable={panelState.planModeAvailable}
|
||||
mcpServers={panelState.mcpServers}
|
||||
onPlanModeChange={handlePlanModeChange}
|
||||
isAgentBusy={isAgentBusy}
|
||||
isStarting={isStarting}
|
||||
@@ -250,6 +339,9 @@ export function ChatInputArea({
|
||||
showRequestChangesTooltip={showRequestChangesTooltip}
|
||||
onRequestChangesTooltipDismiss={onRequestChangesTooltipDismiss}
|
||||
pendingCommentsByFile={pendingCommentsByFile}
|
||||
hasContextComments={
|
||||
panelState.planComments.length > 0 || panelState.pendingPRFeedback.length > 0
|
||||
}
|
||||
submitKey={chatSubmitKey}
|
||||
hasAgentCommands={!!(agentCommands && agentCommands.length > 0)}
|
||||
isFailed={isFailed}
|
||||
@@ -266,6 +358,7 @@ export function ChatInputArea({
|
||||
queuedMessageRef={queuedMessageRef}
|
||||
onQueueEditComplete={handleQueueEditComplete}
|
||||
isPanelFocused={isPanelFocused}
|
||||
onImplementPlan={handleImplementPlan}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TipTapInput } from "./tiptap-input";
|
||||
import { ClarificationInputOverlay } from "./clarification-input-overlay";
|
||||
import { ChatInputFocusHint } from "./chat-input-focus-hint";
|
||||
import { ResizeHandle } from "./resize-handle";
|
||||
import { TodoSummary } from "./todo-summary";
|
||||
@@ -15,7 +14,6 @@ import { ChatInputToolbar } from "./chat-input-toolbar";
|
||||
import { ContextZone } from "./context-items/context-zone";
|
||||
import type { ContextItem } from "@/lib/types/context";
|
||||
import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
|
||||
type TodoItem = { text: string; done?: boolean };
|
||||
|
||||
@@ -28,6 +26,8 @@ export type ChatInputEditorAreaProps = {
|
||||
isDisabled: boolean;
|
||||
hasClarification: boolean;
|
||||
planModeEnabled: boolean;
|
||||
planModeAvailable: boolean;
|
||||
mcpServers: string[];
|
||||
submitKey: "enter" | "cmd_enter";
|
||||
setIsInputFocused: (focused: boolean) => void;
|
||||
sessionId: string | null;
|
||||
@@ -48,6 +48,7 @@ export type ChatInputEditorAreaProps = {
|
||||
contextPopoverOpen: boolean;
|
||||
setContextPopoverOpen: (open: boolean) => void;
|
||||
contextFiles: ContextFile[];
|
||||
onImplementPlan?: () => void;
|
||||
};
|
||||
|
||||
export function ChatInputEditorArea({
|
||||
@@ -59,6 +60,8 @@ export function ChatInputEditorArea({
|
||||
isDisabled,
|
||||
hasClarification,
|
||||
planModeEnabled,
|
||||
planModeAvailable,
|
||||
mcpServers,
|
||||
submitKey,
|
||||
setIsInputFocused,
|
||||
sessionId,
|
||||
@@ -79,6 +82,7 @@ export function ChatInputEditorArea({
|
||||
contextPopoverOpen,
|
||||
setContextPopoverOpen,
|
||||
contextFiles,
|
||||
onImplementPlan,
|
||||
}: ChatInputEditorAreaProps) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
@@ -103,6 +107,7 @@ export function ChatInputEditorArea({
|
||||
planContextEnabled={planContextEnabled}
|
||||
onAgentCommand={handleAgentCommand}
|
||||
onImagePaste={handleImagePaste}
|
||||
onPlanModeChange={onPlanModeChange}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
@@ -112,6 +117,8 @@ export function ChatInputEditorArea({
|
||||
</Tooltip>
|
||||
<ChatInputToolbar
|
||||
planModeEnabled={planModeEnabled}
|
||||
planModeAvailable={planModeAvailable}
|
||||
mcpServers={mcpServers}
|
||||
onPlanModeChange={onPlanModeChange}
|
||||
sessionId={sessionId}
|
||||
taskId={taskId}
|
||||
@@ -129,6 +136,7 @@ export function ChatInputEditorArea({
|
||||
planContextEnabled={planContextEnabled}
|
||||
contextFiles={contextFiles}
|
||||
onToggleFile={onToggleContextFile}
|
||||
onImplementPlan={onImplementPlan}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -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 ? <TodoSummary todos={todoItems} /> : undefined;
|
||||
const clarificationSlot = hasClarification ? (
|
||||
<div className="overflow-auto">
|
||||
<ClarificationInputOverlay
|
||||
message={pendingClarification!}
|
||||
onResolved={onClarificationResolved!}
|
||||
/>
|
||||
</div>
|
||||
) : undefined;
|
||||
return (
|
||||
<ContextZone
|
||||
items={allItems}
|
||||
sessionId={sessionId}
|
||||
queueSlot={queueSlot}
|
||||
todoSlot={todoSlot}
|
||||
clarificationSlot={clarificationSlot}
|
||||
/>
|
||||
<ContextZone items={allItems} sessionId={sessionId} queueSlot={queueSlot} todoSlot={todoSlot} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative flex flex-col border rounded ",
|
||||
isPanelFocused ? "bg-background border-border" : "bg-background/40 border-border",
|
||||
isAgentBusy && "chat-input-running",
|
||||
hasClarification && "border-blue-500/50",
|
||||
isAgentBusy && !planModeEnabled && "chat-input-running",
|
||||
isAgentBusy && planModeEnabled && "chat-input-running-plan",
|
||||
planModeEnabled && !isAgentBusy && "border-slate-400/50",
|
||||
hasClarification && "border-sky-400/50",
|
||||
showRequestChangesTooltip && "animate-pulse border-orange-500",
|
||||
hasPendingComments && "border-amber-500/50",
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
<ResizeHandle visible={isPanelFocused || isInputFocused} {...resizeHandleProps} />
|
||||
<ResizeHandle
|
||||
visible={isPanelFocused || isInputFocused}
|
||||
planModeEnabled={planModeEnabled}
|
||||
{...resizeHandleProps}
|
||||
/>
|
||||
<ChatInputFocusHint visible={showFocusHint} />
|
||||
<ChatInputContextArea {...contextAreaProps} />
|
||||
<ChatInputEditorArea {...editorAreaProps} />
|
||||
|
||||
@@ -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<string, DiffComment[]>;
|
||||
hasContextComments?: boolean;
|
||||
submitKey?: "enter" | "cmd_enter";
|
||||
hasAgentCommands?: boolean;
|
||||
isFailed?: boolean;
|
||||
@@ -75,6 +79,7 @@ type ChatInputContainerProps = {
|
||||
queuedMessageRef?: React.RefObject<QueuedMessageIndicatorHandle | null>;
|
||||
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<ChatInputContainerHandle, ChatInput
|
||||
pendingClarification: props.pendingClarification,
|
||||
onClarificationResolved: props.onClarificationResolved,
|
||||
pendingCommentsByFile: props.pendingCommentsByFile,
|
||||
hasContextComments: props.hasContextComments ?? false,
|
||||
queuedMessage: props.queuedMessage,
|
||||
onCancelQueue: props.onCancelQueue,
|
||||
updateQueueContent: props.updateQueueContent,
|
||||
@@ -259,6 +265,7 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
hasClarification={s.hasClarification}
|
||||
showRequestChangesTooltip={showRequestChangesTooltip}
|
||||
hasPendingComments={s.hasPendingComments}
|
||||
planModeEnabled={props.planModeEnabled}
|
||||
showFocusHint={s.showFocusHint}
|
||||
contextAreaProps={buildContextAreaProps(s, p)}
|
||||
editorAreaProps={buildEditorAreaProps(s, p)}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { IconArrowUp, IconFileTextSpark, IconPlayerPauseFilled, IconAt } from "@tabler/icons-react";
|
||||
import {
|
||||
IconArrowUp,
|
||||
IconFileTextSpark,
|
||||
IconPlayerPauseFilled,
|
||||
IconAt,
|
||||
IconPlugConnected,
|
||||
IconPlugConnectedX,
|
||||
IconRocket,
|
||||
} from "@tabler/icons-react";
|
||||
import { GridSpinner } from "@/components/grid-spinner";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
@@ -16,6 +24,8 @@ import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
|
||||
export type ChatInputToolbarProps = {
|
||||
planModeEnabled: boolean;
|
||||
planModeAvailable?: boolean;
|
||||
mcpServers?: string[];
|
||||
onPlanModeChange: (enabled: boolean) => 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 (
|
||||
<KeyboardShortcutTooltip shortcut={submitShortcut} enabled={!isAgentBusy && !isDisabled}>
|
||||
<KeyboardShortcutTooltip
|
||||
shortcut={submitShortcut}
|
||||
description={planModeEnabled ? "Request plan changes" : undefined}
|
||||
enabled={!isAgentBusy && !isDisabled}
|
||||
>
|
||||
{isAgentBusy ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -70,23 +87,107 @@ function SubmitButton({
|
||||
type="button"
|
||||
variant="default"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full cursor-pointer"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-full cursor-pointer",
|
||||
planModeEnabled && "bg-slate-600 hover:bg-slate-500",
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{isSending ? (
|
||||
<GridSpinner className="text-primary-foreground" />
|
||||
) : (
|
||||
<IconArrowUp className="h-4 w-4" />
|
||||
)}
|
||||
{isSending && <GridSpinner className="text-primary-foreground" />}
|
||||
{!isSending && planModeEnabled && <IconFileTextSpark className="h-4 w-4" />}
|
||||
{!isSending && !planModeEnabled && <IconArrowUp className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
</KeyboardShortcutTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanToggleButton({
|
||||
planModeEnabled,
|
||||
planModeAvailable,
|
||||
onPlanModeChange,
|
||||
}: {
|
||||
planModeEnabled: boolean;
|
||||
planModeAvailable: boolean;
|
||||
onPlanModeChange: (enabled: boolean) => void;
|
||||
}) {
|
||||
const tooltip = planModeAvailable
|
||||
? "Toggle plan mode (Shift+Tab) — Agent collaborates on the plan without implementing changes"
|
||||
: "Toggle plan layout (Shift+Tab) — View and edit the plan (agent cannot read/write it without MCP)";
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 gap-1.5 px-2 hover:bg-muted/40 cursor-pointer",
|
||||
planModeEnabled && planModeAvailable && "bg-slate-500/15 text-slate-400",
|
||||
)}
|
||||
onClick={() => onPlanModeChange(!planModeEnabled)}
|
||||
>
|
||||
<IconFileTextSpark className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function ImplementPlanButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40 text-slate-400"
|
||||
onClick={onClick}
|
||||
>
|
||||
<IconRocket className="h-4 w-4" />
|
||||
<span className="text-xs">Implement</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Implement the plan</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function McpIndicator({ mcpServers }: { mcpServers: string[] }) {
|
||||
const hasMcp = mcpServers.length > 0;
|
||||
const tooltipText = hasMcp
|
||||
? `MCP Servers: ${mcpServers.join(", ")}`
|
||||
: "Agent does not support MCP";
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"h-7 w-7 flex items-center justify-center rounded-md",
|
||||
hasMcp ? "text-foreground" : "text-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
{hasMcp ? (
|
||||
<IconPlugConnected className="h-4 w-4" />
|
||||
) : (
|
||||
<IconPlugConnectedX className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-1 px-1 pt-0 pb-0.5 border-t border-border">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40",
|
||||
planModeEnabled && "bg-primary/15 text-primary",
|
||||
)}
|
||||
onClick={() => onPlanModeChange(!planModeEnabled)}
|
||||
>
|
||||
<IconFileTextSpark className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle plan mode</TooltipContent>
|
||||
</Tooltip>
|
||||
<PlanToggleButton
|
||||
planModeEnabled={planModeEnabled}
|
||||
planModeAvailable={planModeAvailable}
|
||||
onPlanModeChange={onPlanModeChange}
|
||||
/>
|
||||
|
||||
<McpIndicator mcpServers={mcpServers} />
|
||||
|
||||
<ContextPopover
|
||||
open={contextPopoverOpen}
|
||||
@@ -164,11 +256,15 @@ export const ChatInputToolbar = memo(function ChatInputToolbar({
|
||||
/>
|
||||
<TokenUsageDisplay sessionId={sessionId} />
|
||||
<ModelSelector sessionId={sessionId} />
|
||||
{planModeEnabled && !isAgentBusy && onImplementPlan && (
|
||||
<ImplementPlanButton onClick={onImplementPlan} />
|
||||
)}
|
||||
<div className="ml-1">
|
||||
<SubmitButton
|
||||
isAgentBusy={isAgentBusy}
|
||||
isDisabled={isDisabled}
|
||||
isSending={isSending}
|
||||
planModeEnabled={planModeEnabled}
|
||||
onCancel={onCancel}
|
||||
onSubmit={onSubmit}
|
||||
submitShortcut={submitShortcut}
|
||||
|
||||
@@ -42,8 +42,7 @@ async function postClarificationResponse(
|
||||
}
|
||||
|
||||
function handleClarificationResult(result: "ok" | "expired" | "error", onResolved: () => 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 {
|
||||
|
||||
@@ -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({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{clarificationSlot}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<ChatMessage
|
||||
comment={comment}
|
||||
|
||||
@@ -224,30 +224,41 @@ function useAgentStatusData(sessionId: string | null, messages: Message[], isRun
|
||||
return { isTurnActive, runningDuration, elapsedSeconds, displayDuration };
|
||||
}
|
||||
|
||||
function renderActiveStatus(
|
||||
config: { label: string; icon: string },
|
||||
sessionId: string | null,
|
||||
runningData: ReturnType<typeof useAgentStatusData>,
|
||||
): React.ReactNode {
|
||||
switch (config.icon) {
|
||||
case "error":
|
||||
return <AgentErrorStatus config={config} sessionId={sessionId} />;
|
||||
case "warning":
|
||||
return <AgentWarningStatus config={config} />;
|
||||
case "spinner":
|
||||
return (
|
||||
<AgentRunningStatus
|
||||
config={config}
|
||||
elapsedSeconds={runningData.elapsedSeconds}
|
||||
runningDuration={runningData.runningDuration}
|
||||
/>
|
||||
);
|
||||
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 <AgentErrorStatus config={config} sessionId={sessionId} />;
|
||||
if (isWarning && config) return <AgentWarningStatus config={config} />;
|
||||
if (isRunning && config)
|
||||
return (
|
||||
<AgentRunningStatus
|
||||
config={config}
|
||||
elapsedSeconds={elapsedSeconds}
|
||||
runningDuration={runningDuration}
|
||||
/>
|
||||
);
|
||||
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 (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<span className="inline-flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
|
||||
|
||||
@@ -159,9 +159,10 @@ function renderUserMessageBody(
|
||||
showRaw: boolean,
|
||||
hasAttachments: boolean,
|
||||
content: string,
|
||||
rawContent?: string,
|
||||
): React.ReactNode {
|
||||
if (hasContent && showRaw) {
|
||||
return <pre className="whitespace-pre-wrap font-mono text-xs">{content}</pre>;
|
||||
return <pre className="whitespace-pre-wrap font-mono text-xs">{rawContent || content}</pre>;
|
||||
}
|
||||
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 (
|
||||
<div className="flex justify-end gap-1.5 mb-1 flex-wrap">
|
||||
{hasPlanMode && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-purple-500/20 px-2 py-0.5 text-[10px] text-purple-400">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-500/20 px-2 py-0.5 text-[10px] text-slate-400">
|
||||
<IconWand size={10} /> Plan mode
|
||||
</span>
|
||||
)}
|
||||
@@ -267,6 +271,7 @@ function UserMessageContent({
|
||||
contextFiles,
|
||||
hasPlanMode,
|
||||
hasReviewComments,
|
||||
hasHiddenPrompts,
|
||||
hasContent,
|
||||
hasAttachments,
|
||||
} = parseUserMessageMetadata(comment);
|
||||
@@ -279,7 +284,7 @@ function UserMessageContent({
|
||||
hasReviewComments={hasReviewComments}
|
||||
contextFiles={contextFiles}
|
||||
/>
|
||||
<div className="rounded-2xl bg-primary/30 px-4 py-2.5 text-xs overflow-hidden">
|
||||
<div className="rounded-2xl bg-primary/30 px-4 py-2.5 overflow-hidden">
|
||||
{hasAttachments && (
|
||||
<div className={cn("flex flex-wrap gap-2", hasContent && "mb-2")}>
|
||||
{imageAttachments.map((att, index) => (
|
||||
@@ -294,13 +299,20 @@ function UserMessageContent({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{renderUserMessageBody(hasContent, showRaw, hasAttachments, comment.content)}
|
||||
{renderUserMessageBody(
|
||||
hasContent,
|
||||
showRaw,
|
||||
hasAttachments,
|
||||
comment.content,
|
||||
comment.raw_content,
|
||||
)}
|
||||
</div>
|
||||
<MessageActions
|
||||
message={comment}
|
||||
showCopy={true}
|
||||
showTimestamp={true}
|
||||
showRawToggle={true}
|
||||
hasHiddenPrompts={hasHiddenPrompts}
|
||||
showNavigation={!!allMessages && allMessages.length > 0}
|
||||
isRawView={showRaw}
|
||||
onToggleRaw={onToggleRaw}
|
||||
@@ -331,7 +343,7 @@ type AgentMessageProps = {
|
||||
function AgentMessageContent({ comment, showRaw, onToggleRaw, showRichBlocks }: AgentMessageProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 sm:gap-3 w-full group">
|
||||
<div className="flex-1 min-w-0 text-xs">
|
||||
<div className="flex-1 min-w-0">
|
||||
{showRaw ? (
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs bg-muted/20 p-3 rounded-md">
|
||||
{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 (
|
||||
<div className={cn("w-full rounded-lg px-4 py-3 text-xs", className)}>
|
||||
<div className={cn("w-full rounded-lg px-4 py-3", className)}>
|
||||
<div className="flex items-center">
|
||||
<p className="text-[11px] uppercase tracking-wide opacity-70">
|
||||
{comment.requests_input ? (
|
||||
|
||||
@@ -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" ? (
|
||||
<MonacoCodeBlock {...props} />
|
||||
) : (
|
||||
<CodeMirrorCodeBlock {...props} />
|
||||
);
|
||||
if (provider === "monaco") return <MonacoCodeBlock {...props} />;
|
||||
if (provider === "shiki") return <ShikiCodeBlock {...props} />;
|
||||
return <CodeMirrorCodeBlock {...props} />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={onToggleRaw}
|
||||
className={cn(
|
||||
ACTION_BUTTON_SIZE,
|
||||
"flex items-center gap-0.5 rounded",
|
||||
ACTION_BUTTON_HOVER,
|
||||
ACTION_BUTTON_TRANSITION,
|
||||
hasHiddenPrompts ? "h-5 px-1 py-1" : ACTION_BUTTON_SIZE,
|
||||
isRawView && "bg-muted text-foreground",
|
||||
)}
|
||||
title={isRawView ? "Show formatted" : "Show raw text"}
|
||||
aria-label={isRawView ? "Show formatted message" : "Show raw text"}
|
||||
>
|
||||
<IconCode className="h-full w-full" />
|
||||
<IconCode className="h-3 w-3" />
|
||||
{hasHiddenPrompts && <IconEyeCode className="h-3 w-3" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<div className="flex items-center gap-2 mt-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{showCopy && <CopyButton copied={copied} onCopy={handleCopy} />}
|
||||
{showRawToggle && onToggleRaw && (
|
||||
<RawToggleButton isRawView={isRawView} onToggleRaw={onToggleRaw} />
|
||||
<RawToggleButton
|
||||
isRawView={isRawView}
|
||||
onToggleRaw={onToggleRaw}
|
||||
hasHiddenPrompts={hasHiddenPrompts}
|
||||
/>
|
||||
)}
|
||||
{showNavigation && (
|
||||
<NavigationButtons
|
||||
|
||||
@@ -4,11 +4,17 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
type ResizeHandleProps = {
|
||||
visible: boolean;
|
||||
planModeEnabled?: boolean;
|
||||
onMouseDown: (e: React.MouseEvent) => void;
|
||||
onDoubleClick: () => void;
|
||||
};
|
||||
|
||||
export function ResizeHandle({ visible, onMouseDown, onDoubleClick }: ResizeHandleProps) {
|
||||
export function ResizeHandle({
|
||||
visible,
|
||||
planModeEnabled,
|
||||
onMouseDown,
|
||||
onDoubleClick,
|
||||
}: ResizeHandleProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -22,7 +28,14 @@ export function ResizeHandle({ visible, onMouseDown, onDoubleClick }: ResizeHand
|
||||
onDoubleClick={onDoubleClick}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="w-8 h-0.5 bg-border rounded-full hover:bg-muted-foreground transition-colors" />
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-0.5 rounded-full transition-colors",
|
||||
planModeEnabled
|
||||
? "bg-slate-400/60 hover:bg-slate-400"
|
||||
: "bg-border hover:bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<TipTapInputHandle, TipTapInputProps>(funct
|
||||
disabled = false,
|
||||
className,
|
||||
planModeEnabled = false,
|
||||
onPlanModeChange,
|
||||
submitKey = "cmd_enter",
|
||||
onFocus,
|
||||
onBlur,
|
||||
@@ -385,6 +387,7 @@ export const TipTapInput = forwardRef<TipTapInputHandle, TipTapInputProps>(funct
|
||||
disabled,
|
||||
className,
|
||||
planModeEnabled,
|
||||
onPlanModeChange,
|
||||
submitKey,
|
||||
onFocus,
|
||||
onBlur,
|
||||
|
||||
@@ -26,6 +26,7 @@ type UseChatInputContainerParams = {
|
||||
pendingClarification: Message | null | undefined;
|
||||
onClarificationResolved: (() => void) | undefined;
|
||||
pendingCommentsByFile: Record<string, DiffComment[]> | undefined;
|
||||
hasContextComments: boolean;
|
||||
queuedMessage: string | null | undefined;
|
||||
onCancelQueue: (() => void) | undefined;
|
||||
updateQueueContent: ((content: string) => Promise<void>) | 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,
|
||||
|
||||
@@ -25,6 +25,8 @@ type UseChatInputStateProps = {
|
||||
isSending: boolean;
|
||||
contextItems: ContextItem[];
|
||||
pendingCommentsByFile?: Record<string, DiffComment[]>;
|
||||
/** 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[] => {
|
||||
|
||||
@@ -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<HTMLElement>(".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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<boolean | undefined>,
|
||||
submitKeyRef: React.RefObject<"enter" | "cmd_enter">,
|
||||
onSubmitRef: React.RefObject<(() => void) | undefined>,
|
||||
planModeEnabledRef: React.RefObject<boolean>,
|
||||
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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -369,7 +369,7 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({
|
||||
}, [isRunning]);
|
||||
|
||||
return (
|
||||
<SessionPanelContent ref={messagesContainerRef} className="relative p-4">
|
||||
<SessionPanelContent ref={messagesContainerRef} className="relative p-4 chat-message-list">
|
||||
<MessageListStatus
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { VirtualizedMessageList } from "@/components/task/chat/virtualized-messa
|
||||
import { useIsTaskArchived } from "./task-archived-context";
|
||||
import { useChatPanelState } from "./chat/use-chat-panel-state";
|
||||
import { ChatInputArea, useSubmitHandler, useChatPanelHandlers } from "./chat/chat-input-area";
|
||||
import { ClarificationInputOverlay } from "./chat/clarification-input-overlay";
|
||||
|
||||
type TaskChatPanelProps = {
|
||||
onSend?: (message: string) => 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}
|
||||
/>
|
||||
</PanelBody>
|
||||
{pendingClarification && !isArchived && (
|
||||
<div className="flex-shrink-0 border-t border-sky-400/30 bg-card px-1">
|
||||
<ClarificationInputOverlay
|
||||
message={pendingClarification}
|
||||
onResolved={handleClarificationResolved}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isArchived ? (
|
||||
<div className="bg-muted/50 flex-shrink-0 px-4 py-3 text-center text-sm text-muted-foreground border-t">
|
||||
This task is archived and read-only.
|
||||
|
||||
@@ -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({
|
||||
<TopBarRight
|
||||
activeSessionId={activeSessionId}
|
||||
baseBranch={baseBranch}
|
||||
gitStatus={git}
|
||||
showDebugOverlay={showDebugOverlay}
|
||||
onToggleDebugOverlay={onToggleDebugOverlay}
|
||||
isArchived={isArchived}
|
||||
@@ -270,14 +270,13 @@ function RemoteExecutorIndicator({
|
||||
);
|
||||
}
|
||||
|
||||
/** Left section: breadcrumbs, branch pill, git status badges */
|
||||
/** Left section: breadcrumbs, branch pill */
|
||||
function TopBarLeft({
|
||||
taskTitle,
|
||||
repositoryName,
|
||||
displayBranch,
|
||||
repositoryPath,
|
||||
worktreePath,
|
||||
gitStatus,
|
||||
isRemoteExecutor,
|
||||
remoteExecutorName,
|
||||
remoteExecutorType,
|
||||
@@ -291,7 +290,6 @@ function TopBarLeft({
|
||||
displayBranch?: string;
|
||||
repositoryPath?: string | null;
|
||||
worktreePath?: string | null;
|
||||
gitStatus: { ahead: number; behind: number; remote_branch?: string | null };
|
||||
isRemoteExecutor?: boolean;
|
||||
remoteExecutorName?: string | null;
|
||||
remoteExecutorType?: string | null;
|
||||
@@ -335,7 +333,6 @@ function TopBarLeft({
|
||||
worktreePath={worktreePath}
|
||||
/>
|
||||
|
||||
<GitAheadBehindBadges gitStatus={gitStatus} />
|
||||
<RemoteExecutorIndicator
|
||||
isRemoteExecutor={isRemoteExecutor}
|
||||
remoteExecutorName={remoteExecutorName}
|
||||
@@ -389,10 +386,11 @@ function GitAheadBehindBadges({
|
||||
);
|
||||
}
|
||||
|
||||
/** Right section: debug toggle, document controls, editors, VCS, settings */
|
||||
/** Right section: git badges, debug toggle, document controls, editors, VCS, settings */
|
||||
function TopBarRight({
|
||||
activeSessionId,
|
||||
baseBranch,
|
||||
gitStatus,
|
||||
showDebugOverlay,
|
||||
onToggleDebugOverlay,
|
||||
isArchived,
|
||||
@@ -400,6 +398,7 @@ function TopBarRight({
|
||||
}: {
|
||||
activeSessionId?: string | null;
|
||||
baseBranch?: string;
|
||||
gitStatus: { ahead: number; behind: number; remote_branch?: string | null };
|
||||
showDebugOverlay?: boolean;
|
||||
onToggleDebugOverlay?: () => void;
|
||||
isArchived?: boolean;
|
||||
@@ -424,13 +423,14 @@ function TopBarRight({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<GitAheadBehindBadges gitStatus={gitStatus} />
|
||||
<DocumentControls activeSessionId={activeSessionId ?? null} />
|
||||
{!isArchived && (
|
||||
<>
|
||||
<LayoutPresetSelector />
|
||||
<EditorsMenu activeSessionId={activeSessionId ?? null} />
|
||||
<PRTopbarButton />
|
||||
<VcsSplitButton sessionId={activeSessionId ?? null} baseBranch={baseBranch} />
|
||||
<LayoutPresetSelector />
|
||||
<EditorsMenu activeSessionId={activeSessionId ?? null} />
|
||||
</>
|
||||
)}
|
||||
<Tooltip>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<kandev-system>\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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -584,15 +584,28 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
|
||||
// Clear pinned overrides so fresh default gets clean preset widths
|
||||
const freshPinned = new Map<string, number>();
|
||||
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);
|
||||
|
||||
@@ -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<EditorContext, EditorProvider[]> = {
|
||||
"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<EditorContext, EditorProvider[]> = {
|
||||
const DEFAULT_PROVIDERS: Record<EditorContext, EditorProvider> = {
|
||||
"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<EditorResolverStore>()(
|
||||
}),
|
||||
{
|
||||
name: "kandev-editor-providers",
|
||||
version: 1,
|
||||
version: 3,
|
||||
migrate: (persisted) => {
|
||||
const state = persisted as { providers: Record<EditorContext, EditorProvider> };
|
||||
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",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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")] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -183,6 +183,7 @@ export type MessageAddedPayload = {
|
||||
author_type: "user" | "agent";
|
||||
author_id?: string;
|
||||
content: string;
|
||||
raw_content?: string;
|
||||
type?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
requests_input?: boolean;
|
||||
|
||||
@@ -563,6 +563,7 @@ export type Message = {
|
||||
author_type: MessageAuthorType;
|
||||
author_id?: string;
|
||||
content: string;
|
||||
raw_content?: string;
|
||||
type: MessageType;
|
||||
metadata?: Record<string, unknown>;
|
||||
requests_input?: boolean;
|
||||
|
||||
@@ -18,6 +18,7 @@ export function registerMessagesHandlers(store: StoreApi<AppState>): 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<AppState>): 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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user