feat: several UX improvements (#272)

This commit is contained in:
Carlos Florêncio
2026-02-24 00:48:55 +00:00
committed by GitHub
parent aea8dc7b67
commit 32f7ceb319
68 changed files with 1481 additions and 613 deletions
+1
View File
@@ -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"
+6 -4
View File
@@ -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 {
+1
View File
@@ -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"`
+3 -1
View File
@@ -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"`
+19 -1
View File
@@ -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)
+6 -1
View File
@@ -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.
+23 -1
View File
@@ -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
}
}
+1
View File
@@ -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"`
+3
View File
@@ -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
View File
@@ -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;
}
+4 -8
View File
@@ -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,
});
}
-1
View File
@@ -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",
)}
+27 -4
View File
@@ -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 &gt; 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,
);
}
+10 -246
View File
@@ -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,
});
}
+122 -118
View File
@@ -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,
};
}
+7 -19
View File
@@ -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>
+107 -14
View File
@@ -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.
+8 -8
View File
@@ -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 };
}
+10 -7
View File
@@ -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,
+13
View File
@@ -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,
+4
View File
@@ -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;
+1
View File
@@ -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";
+20 -7
View File
@@ -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);
+10 -5
View File
@@ -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",
},
};
},
},
+1 -1
View File
@@ -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")] },
],
},
+1
View File
@@ -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;
+1
View File
@@ -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;
+2
View File
@@ -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,
+1
View File
@@ -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",