fix: resolve acp chat ux issues with permissions, plans, and tool states (#428)

This commit is contained in:
Carlos Florêncio
2026-03-12 23:22:26 +00:00
committed by GitHub
parent ca926a7614
commit 9af1b05bfb
24 changed files with 1131 additions and 398 deletions
@@ -151,6 +151,9 @@ type AgentStreamEventData struct {
// Plan entries (from "plan" event — ACP/Codex agent todos)
PlanEntries []streams.PlanEntry `json:"plan_entries,omitempty"`
// PlanContent contains rich markdown plan content (from "agent_plan" event).
PlanContent string `json:"plan_content,omitempty"`
}
// AgentStreamEventPayload is the payload for agent stream events (WebSocket streaming).
@@ -158,6 +158,7 @@ func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, even
ConfigOptions: event.ConfigOptions,
Usage: event.Usage,
PlanEntries: event.PlanEntries,
PlanContent: event.PlanContent,
}
// Build agent event message payload
@@ -521,6 +521,10 @@ func (a *Adapter) Prompt(ctx context.Context, message string, attachments []v1.M
return err
}
// Cancel any tool calls still in-flight (e.g. a denied permission leaves the
// tool_call without a terminal status update from the agent).
a.cancelActiveToolCalls(sessionID)
// Emit complete event via the stream, including the StopReason from the agent.
// This normalizes ACP behavior to match other adapters (stream-json, amp, copilot, opencode).
a.logger.Debug("emitting complete event after prompt",
@@ -735,7 +739,13 @@ func (a *Adapter) handleACPUpdate(n acp.SessionNotification) {
}
}
sessionID := string(n.SessionId)
event := a.convertNotification(n)
if event == nil {
// Try untyped updates not yet supported by the ACP SDK.
event = a.tryConvertUntypedUpdate(rawData, sessionID)
}
if event != nil {
shared.LogNormalizedEvent(shared.ProtocolACP, a.agentID, event)
shared.TraceProtocolEvent(a.getPromptTraceCtx(), shared.ProtocolACP, a.agentID,
@@ -743,7 +753,7 @@ func (a *Adapter) handleACPUpdate(n acp.SessionNotification) {
a.sendUpdate(*event)
} else if updateJSON, err := json.Marshal(n.Update); err == nil {
a.logger.Warn("unhandled ACP session notification",
zap.String("session_id", string(n.SessionId)),
zap.String("session_id", sessionID),
zap.String("update_json", string(updateJSON)))
}
}
@@ -799,11 +809,62 @@ func (a *Adapter) convertNotification(n acp.SessionNotification) *AgentEvent {
SessionID: sessionID,
CurrentModeID: string(u.CurrentModeUpdate.CurrentModeId),
}
case u.ConfigOptionUpdate != nil:
configOptions := convertACPConfigOptions(u.ConfigOptionUpdate.ConfigOptions)
if len(configOptions) > 0 {
return &AgentEvent{
Type: streams.EventTypeSessionModels,
SessionID: sessionID,
ConfigOptions: configOptions,
}
}
}
return nil
}
// acpUsageUpdate represents the ACP "usage_update" session notification.
// TODO: Replace with acp.SessionUsageUpdate when the ACP SDK adds native support.
type acpUsageUpdate struct {
SessionUpdate string `json:"sessionUpdate"`
Size int64 `json:"size"`
Used int64 `json:"used"`
Cost *struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
} `json:"cost,omitempty"`
}
// tryConvertUntypedUpdate handles ACP session update types not yet supported by the SDK.
// When the SDK adds native support, move the handling into convertNotification and delete this.
func (a *Adapter) tryConvertUntypedUpdate(rawNotification []byte, sessionID string) *AgentEvent {
var envelope struct {
Update json.RawMessage `json:"update"`
}
if err := json.Unmarshal(rawNotification, &envelope); err != nil {
return nil
}
var usage acpUsageUpdate
if err := json.Unmarshal(envelope.Update, &usage); err != nil {
return nil
}
if usage.SessionUpdate != "usage_update" || usage.Size <= 0 {
return nil
}
remaining := max(usage.Size-usage.Used, 0)
return &AgentEvent{
Type: streams.EventTypeContextWindow,
SessionID: sessionID,
ContextWindowSize: usage.Size,
ContextWindowUsed: usage.Used,
ContextWindowRemaining: remaining,
ContextEfficiency: float64(usage.Used) / float64(usage.Size) * 100,
}
}
// convertMessageChunk converts an ACP ContentBlock to an AgentEvent, handling multimodal content.
// For text-only messages, sets the Text field for backward compatibility.
// For non-text content, populates ContentBlocks.
@@ -836,8 +897,13 @@ func (a *Adapter) convertMessageChunk(sessionID string, content acp.ContentBlock
// convertAvailableCommands converts an ACP AvailableCommandsUpdate to an AgentEvent,
// including input hints when available.
func (a *Adapter) convertAvailableCommands(sessionID string, update *acp.SessionAvailableCommandsUpdate) *AgentEvent {
commands := make([]streams.AvailableCommand, len(update.AvailableCommands))
for i, cmd := range update.AvailableCommands {
seen := make(map[string]struct{}, len(update.AvailableCommands))
commands := make([]streams.AvailableCommand, 0, len(update.AvailableCommands))
for _, cmd := range update.AvailableCommands {
if _, dup := seen[cmd.Name]; dup {
continue
}
seen[cmd.Name] = struct{}{}
ac := streams.AvailableCommand{
Name: cmd.Name,
Description: cmd.Description,
@@ -845,7 +911,7 @@ func (a *Adapter) convertAvailableCommands(sessionID string, update *acp.Session
if cmd.Input != nil && cmd.Input.Unstructured != nil {
ac.InputHint = cmd.Input.Unstructured.Hint
}
commands[i] = ac
commands = append(commands, ac)
}
return &AgentEvent{
Type: streams.EventTypeAvailableCommands,
@@ -1191,32 +1257,54 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session
status = toolStatusComplete
}
// Look up the stored payload and update with result if we have rawOutput
var normalizedPayload *streams.NormalizedPayload
if tcu.RawOutput != nil {
a.mu.Lock()
if payload, ok := a.activeToolCalls[toolCallID]; ok {
a.normalizer.NormalizeToolResult(payload, tcu.RawOutput)
normalizedPayload = payload
if status == toolStatusComplete || status == toolStatusError {
delete(a.activeToolCalls, toolCallID)
isTerminal := status == toolStatusComplete || status == toolStatusError || status == "cancelled"
a.mu.Lock()
payload := a.activeToolCalls[toolCallID]
// Update stored payload with incremental rawInput (e.g. Claude Code sends
// command/cwd in a tool_call_update after the initial empty tool_call)
if tcu.RawInput != nil && payload != nil {
a.normalizer.UpdatePayloadInput(payload, tcu.RawInput)
}
// Update stored payload with tool result output
if tcu.RawOutput != nil && payload != nil {
a.normalizer.NormalizeToolResult(payload, tcu.RawOutput)
}
if isTerminal {
delete(a.activeToolCalls, toolCallID)
}
a.mu.Unlock()
// When a switch_mode tool carries a plan (e.g. ExitPlanMode), emit it
// as an agent_plan event so the orchestrator creates a visible plan message.
if tcu.RawInput != nil {
if inputMap, ok := tcu.RawInput.(map[string]any); ok {
if planContent, ok := inputMap["plan"].(string); ok && planContent != "" {
a.sendUpdate(AgentEvent{
Type: streams.EventTypeAgentPlan,
SessionID: sessionID,
PlanContent: planContent,
})
}
}
a.mu.Unlock()
} else if status == toolStatusComplete || status == toolStatusError {
// Clean up even without output
a.mu.Lock()
normalizedPayload = a.activeToolCalls[toolCallID]
delete(a.activeToolCalls, toolCallID)
a.mu.Unlock()
}
// Extract title from update if present
var title string
if tcu.Title != nil {
title = *tcu.Title
}
return &AgentEvent{
Type: streams.EventTypeToolUpdate,
SessionID: sessionID,
ToolCallID: toolCallID,
ToolTitle: title,
ToolStatus: status,
NormalizedPayload: normalizedPayload,
NormalizedPayload: payload,
ToolCallContents: a.convertToolCallContents(tcu.Content),
}
}
@@ -1236,22 +1324,27 @@ func (a *Adapter) handlePermissionRequest(ctx context.Context, req *PermissionRe
sessionID = fallbackSessionID
}
// Emit a tool_call event so a message is created in the database.
// This is needed because permission requests bypass the normal ToolCall notification flow.
// Without this, when the tool completes (ToolCallUpdate), there's no message to update.
toolCallEvent := AgentEvent{
Type: streams.EventTypeToolCall,
SessionID: sessionID,
ToolCallID: req.ToolCallID,
ToolName: req.ActionType, // Use action type as tool name (e.g., "run_shell_command")
ToolTitle: req.Title,
ToolStatus: "pending_permission", // Mark as pending permission
}
// Only emit a synthetic tool_call event if no ToolCall notification preceded this.
// When a ToolCall notification exists (tracked in activeToolCalls), the message
// was already created by convertToolCallUpdate → handleToolCallEvent.
// Emitting a second tool_call for the same ID creates duplicate messages in the UI.
a.mu.RLock()
_, alreadyTracked := a.activeToolCalls[req.ToolCallID]
a.mu.RUnlock()
// Emit the tool_call event
a.sendUpdate(toolCallEvent)
a.logger.Debug("emitted tool_call event for permission request",
zap.String("tool_call_id", req.ToolCallID))
if !alreadyTracked {
toolCallEvent := AgentEvent{
Type: streams.EventTypeToolCall,
SessionID: sessionID,
ToolCallID: req.ToolCallID,
ToolName: req.ActionType,
ToolTitle: req.Title,
ToolStatus: "pending_permission",
}
a.sendUpdate(toolCallEvent)
a.logger.Debug("emitted synthetic tool_call for permission (no prior ToolCall)",
zap.String("tool_call_id", req.ToolCallID))
}
if handler == nil {
// Auto-approve if no handler
@@ -702,6 +702,162 @@ func TestConvertAvailableCommands_Empty(t *testing.T) {
}
}
func TestConvertAvailableCommands_Dedup(t *testing.T) {
a := newTestAdapter()
update := &acp.SessionAvailableCommandsUpdate{
AvailableCommands: []acp.AvailableCommand{
{Name: "pr-comments", Description: "Address PR review comments"},
{Name: "pr-comments", Description: "Address PR review comments"},
{Name: "pr-comments", Description: "Address PR review comments"},
{Name: "push-pr", Description: "Push and open PR"},
},
}
result := a.convertAvailableCommands("session-dedup", update)
if len(result.AvailableCommands) != 2 {
t.Fatalf("expected 2 commands after dedup, got %d", len(result.AvailableCommands))
}
if result.AvailableCommands[0].Name != "pr-comments" {
t.Errorf("command[0].Name = %q, want %q", result.AvailableCommands[0].Name, "pr-comments")
}
if result.AvailableCommands[1].Name != "push-pr" {
t.Errorf("command[1].Name = %q, want %q", result.AvailableCommands[1].Name, "push-pr")
}
}
func TestConvertAvailableCommands_DedupPreservesFirst(t *testing.T) {
a := newTestAdapter()
update := &acp.SessionAvailableCommandsUpdate{
AvailableCommands: []acp.AvailableCommand{
{Name: "review", Description: "First description"},
{Name: "review", Description: "Second description"},
},
}
result := a.convertAvailableCommands("session-first", update)
if len(result.AvailableCommands) != 1 {
t.Fatalf("expected 1 command after dedup, got %d", len(result.AvailableCommands))
}
if result.AvailableCommands[0].Description != "First description" {
t.Errorf("Description = %q, want %q (first occurrence should win)",
result.AvailableCommands[0].Description, "First description")
}
}
func TestConvertAvailableCommands_NoDuplicates(t *testing.T) {
a := newTestAdapter()
update := &acp.SessionAvailableCommandsUpdate{
AvailableCommands: []acp.AvailableCommand{
{Name: "commit", Description: "Commit changes"},
{Name: "review", Description: "Review code"},
{Name: "push-pr", Description: "Push and open PR"},
},
}
result := a.convertAvailableCommands("session-nodup", update)
if len(result.AvailableCommands) != 3 {
t.Fatalf("expected 3 commands (no dedup needed), got %d", len(result.AvailableCommands))
}
}
// --- tryConvertUntypedUpdate ---
func TestTryConvertUntypedUpdate_UsageUpdate(t *testing.T) {
a := newTestAdapter()
raw := []byte(`{"sessionId":"s1","update":{"sessionUpdate":"usage_update","size":200000,"used":56047,"cost":{"amount":9.76,"currency":"USD"}}}`)
result := a.tryConvertUntypedUpdate(raw, "s1")
if result == nil {
t.Fatal("expected non-nil result for usage_update")
}
if result.Type != streams.EventTypeContextWindow {
t.Errorf("Type = %q, want %q", result.Type, streams.EventTypeContextWindow)
}
if result.SessionID != "s1" {
t.Errorf("SessionID = %q, want %q", result.SessionID, "s1")
}
if result.ContextWindowSize != 200000 {
t.Errorf("ContextWindowSize = %d, want 200000", result.ContextWindowSize)
}
if result.ContextWindowUsed != 56047 {
t.Errorf("ContextWindowUsed = %d, want 56047", result.ContextWindowUsed)
}
if result.ContextWindowRemaining != 143953 {
t.Errorf("ContextWindowRemaining = %d, want 143953", result.ContextWindowRemaining)
}
expectedEff := float64(56047) / float64(200000) * 100
if result.ContextEfficiency != expectedEff {
t.Errorf("ContextEfficiency = %f, want %f", result.ContextEfficiency, expectedEff)
}
}
func TestTryConvertUntypedUpdate_ZeroUsed(t *testing.T) {
a := newTestAdapter()
raw := []byte(`{"sessionId":"s1","update":{"sessionUpdate":"usage_update","size":200000,"used":0}}`)
result := a.tryConvertUntypedUpdate(raw, "s1")
if result == nil {
t.Fatal("expected non-nil result")
}
if result.ContextWindowRemaining != 200000 {
t.Errorf("ContextWindowRemaining = %d, want 200000", result.ContextWindowRemaining)
}
if result.ContextEfficiency != 0 {
t.Errorf("ContextEfficiency = %f, want 0", result.ContextEfficiency)
}
}
func TestTryConvertUntypedUpdate_UsedExceedsSize(t *testing.T) {
a := newTestAdapter()
raw := []byte(`{"sessionId":"s1","update":{"sessionUpdate":"usage_update","size":100000,"used":110000}}`)
result := a.tryConvertUntypedUpdate(raw, "s1")
if result == nil {
t.Fatal("expected non-nil result")
}
if result.ContextWindowRemaining != 0 {
t.Errorf("ContextWindowRemaining = %d, want 0 (clamped)", result.ContextWindowRemaining)
}
}
func TestTryConvertUntypedUpdate_UnknownUpdateType(t *testing.T) {
a := newTestAdapter()
raw := []byte(`{"sessionId":"s1","update":{"sessionUpdate":"something_else","foo":"bar"}}`)
result := a.tryConvertUntypedUpdate(raw, "s1")
if result != nil {
t.Errorf("expected nil for unknown update type, got %+v", result)
}
}
func TestTryConvertUntypedUpdate_InvalidJSON(t *testing.T) {
a := newTestAdapter()
result := a.tryConvertUntypedUpdate([]byte(`{invalid`), "s1")
if result != nil {
t.Errorf("expected nil for invalid JSON, got %+v", result)
}
}
func TestTryConvertUntypedUpdate_ZeroSize(t *testing.T) {
a := newTestAdapter()
raw := []byte(`{"sessionId":"s1","update":{"sessionUpdate":"usage_update","size":0,"used":0}}`)
result := a.tryConvertUntypedUpdate(raw, "s1")
if result != nil {
t.Errorf("expected nil for zero size (division by zero guard), got %+v", result)
}
}
// --- emitInitialModeState ---
func TestEmitInitialModeState(t *testing.T) {
@@ -188,8 +188,19 @@ func parseFileList(output string) []string {
// parseShellOutput parses ACP's XML-like shell output format.
// Format: "...<return-code>N</return-code>...<output>...</output>..."
// Falls back to treating the entire string as stdout when no XML tags are found
// (e.g. Claude Code sends plain string rawOutput).
// Returns exit code, stdout, and stderr (stderr from <stderr> tag if present).
func parseShellOutput(output string) (exitCode int, stdout, stderr string) {
hasXMLTags := strings.Contains(output, "<return-code>") ||
strings.Contains(output, "<output>") ||
strings.Contains(output, "<stderr>")
if !hasXMLTags {
// Plain string output (e.g. Claude Code ACP) — treat entire string as stdout
return 0, strings.TrimSpace(output), ""
}
// Extract return code
if start := strings.Index(output, "<return-code>"); start != -1 {
start += len("<return-code>")
@@ -220,6 +231,28 @@ func parseShellOutput(output string) (exitCode int, stdout, stderr string) {
return exitCode, stdout, stderr
}
// UpdatePayloadInput updates a stored NormalizedPayload with new rawInput data.
// This handles agents (e.g. Claude Code) that send rawInput incrementally
// via tool_call_update events after the initial tool_call.
func (n *Normalizer) UpdatePayloadInput(payload *streams.NormalizedPayload, rawInput any) {
inputMap, ok := rawInput.(map[string]any)
if !ok || payload == nil {
return
}
if shellExec := payload.ShellExec(); shellExec != nil {
if cmd := shared.GetString(inputMap, "command"); cmd != "" && shellExec.Command == "" {
shellExec.Command = cmd
}
if cwd := shared.GetString(inputMap, "cwd"); cwd != "" && shellExec.WorkDir == "" {
shellExec.WorkDir = cwd
}
if desc := shared.GetString(inputMap, "description"); desc != "" && shellExec.Description == "" {
shellExec.Description = desc
}
}
}
// normalizeEdit converts ACP edit tool data.
func (n *Normalizer) normalizeEdit(args map[string]any) *streams.NormalizedPayload {
rawInput, _ := args["raw_input"].(map[string]any)
@@ -547,6 +547,113 @@ func TestNormalizerExecute(t *testing.T) {
})
}
// TestParseShellOutputPlainString tests that plain string output (Claude Code format)
// is correctly treated as stdout.
func TestParseShellOutputPlainString(t *testing.T) {
normalizer := NewNormalizer()
t.Run("plain string rawOutput becomes stdout", func(t *testing.T) {
payload := normalizer.NormalizeToolCall("execute", map[string]any{
"kind": "execute",
"raw_input": map[string]any{"command": "pwd"},
})
// Claude Code sends rawOutput as a direct string, not XML-wrapped
normalizer.NormalizeToolResult(payload, "/Users/cfl/Projects/1code")
if payload.ShellExec().Output == nil {
t.Fatal("expected Output to be set")
}
if payload.ShellExec().Output.Stdout != "/Users/cfl/Projects/1code" {
t.Errorf("expected Stdout '/Users/cfl/Projects/1code', got %q", payload.ShellExec().Output.Stdout)
}
if payload.ShellExec().Output.ExitCode != 0 {
t.Errorf("expected ExitCode 0, got %d", payload.ShellExec().Output.ExitCode)
}
})
t.Run("XML-wrapped output still works", func(t *testing.T) {
payload := normalizer.NormalizeToolCall("execute", map[string]any{
"kind": "execute",
"raw_input": map[string]any{"command": "ls"},
})
normalizer.NormalizeToolResult(payload, map[string]any{
"rawOutput": map[string]any{
"output": "<return-code>\n0\n</return-code>\n<output>\nfile1.txt\nfile2.txt\n</output>",
},
})
if payload.ShellExec().Output == nil {
t.Fatal("expected Output to be set")
}
if payload.ShellExec().Output.Stdout != "file1.txt\nfile2.txt" {
t.Errorf("expected Stdout 'file1.txt\\nfile2.txt', got %q", payload.ShellExec().Output.Stdout)
}
})
}
// TestUpdatePayloadInput tests incremental rawInput updates (Claude Code pattern).
func TestUpdatePayloadInput(t *testing.T) {
normalizer := NewNormalizer()
t.Run("updates empty command from rawInput", func(t *testing.T) {
// Initial tool_call with empty rawInput (Claude Code sends this)
payload := normalizer.NormalizeToolCall("execute", map[string]any{
"kind": "execute",
"raw_input": map[string]any{},
})
if payload.ShellExec().Command != "" {
t.Errorf("expected empty initial Command, got %q", payload.ShellExec().Command)
}
// Subsequent tool_call_update provides the actual command
normalizer.UpdatePayloadInput(payload, map[string]any{
"command": "pwd",
"description": "Print working directory",
})
if payload.ShellExec().Command != "pwd" {
t.Errorf("expected Command 'pwd', got %q", payload.ShellExec().Command)
}
if payload.ShellExec().Description != "Print working directory" {
t.Errorf("expected Description 'Print working directory', got %q", payload.ShellExec().Description)
}
})
t.Run("does not overwrite existing command", func(t *testing.T) {
payload := normalizer.NormalizeToolCall("execute", map[string]any{
"kind": "execute",
"raw_input": map[string]any{
"command": "ls -la",
},
})
normalizer.UpdatePayloadInput(payload, map[string]any{
"command": "pwd",
})
if payload.ShellExec().Command != "ls -la" {
t.Errorf("expected Command 'ls -la' (unchanged), got %q", payload.ShellExec().Command)
}
})
t.Run("handles nil payload gracefully", func(t *testing.T) {
// Should not panic
normalizer.UpdatePayloadInput(nil, map[string]any{"command": "pwd"})
})
t.Run("handles non-map rawInput gracefully", func(t *testing.T) {
payload := normalizer.NormalizeToolCall("execute", map[string]any{
"kind": "execute",
"raw_input": map[string]any{},
})
// Should not panic
normalizer.UpdatePayloadInput(payload, "not a map")
})
}
// TestSplitLines tests the line splitting utility.
func TestSplitLines(t *testing.T) {
tests := []struct {
@@ -417,6 +417,10 @@ func (s *Server) handleWSPrompt(ctx context.Context, msg *ws.Message) *ws.Messag
return resp
}
// Cancel any pending permissions so the agent isn't blocked waiting for
// the user to approve a previous tool call while processing the new prompt.
s.procMgr.CancelPendingPermissions()
// Start prompt processing asynchronously.
// Completion is signaled via the WebSocket complete event, not this response.
// Pass ctx (which carries remote trace context) so acp.prompt spans become children of the session.
@@ -2,6 +2,7 @@
package api
import (
"context"
"net/http"
"time"
@@ -124,7 +125,13 @@ func (m *ControlServer) handleDeleteInstance(c *gin.Context) {
return
}
err := m.instMgr.StopInstance(c.Request.Context(), id)
// Use a detached context so cleanup isn't canceled when the HTTP client disconnects.
// The ControlClient has a 30s http.Client.Timeout; if it fires, the request context
// is canceled, which cascades into force-killing processes mid-cleanup.
stopCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := m.instMgr.StopInstance(stopCtx, id)
if err != nil {
m.logger.Error("failed to stop instance", zap.String("id", id), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
@@ -913,6 +913,9 @@ func (m *Manager) handlePermissionRequest(ctx context.Context, req *adapter.Perm
zap.String("pending_id", pendingID),
zap.String("option_id", resp.OptionID),
zap.Bool("cancelled", resp.Cancelled))
if resp.Cancelled {
m.sendPermissionCancelledNotification(pending)
}
return resp, nil
case <-ctx.Done():
m.logger.Warn("permission request context cancelled",
@@ -1045,6 +1048,26 @@ func (m *Manager) RespondToPermission(pendingID string, optionID string, cancell
}
}
// CancelPendingPermissions cancels all pending permission requests.
// Called before sending a new prompt so the agent isn't blocked waiting for responses.
func (m *Manager) CancelPendingPermissions() {
m.permissionMu.RLock()
pending := make([]*PendingPermission, 0, len(m.pendingPermissions))
for _, p := range m.pendingPermissions {
pending = append(pending, p)
}
m.permissionMu.RUnlock()
for _, p := range pending {
m.logger.Info("cancelling pending permission before new prompt",
zap.String("pending_id", p.ID))
select {
case p.ResponseCh <- &adapter.PermissionResponse{Cancelled: true}:
default:
}
}
}
// Shell returns the embedded shell session, or nil if not available
func (m *Manager) Shell() *shell.Session {
m.mu.RLock()
@@ -17,6 +17,9 @@ const (
// EventTypePlan indicates agent plan/task list updates.
EventTypePlan = "plan"
// EventTypeAgentPlan indicates rich plan content (markdown) from the agent.
EventTypeAgentPlan = "agent_plan"
// EventTypeComplete indicates the turn or session has completed.
EventTypeComplete = "complete"
@@ -116,11 +119,14 @@ type AgentEvent struct {
// Populated when tools modify files, providing the aggregated diff.
Diff string `json:"diff,omitempty"`
// --- Plan fields (for "plan" type) ---
// --- Plan fields (for "plan" and "agent_plan" types) ---
// PlanEntries contains the agent's execution plan/task list.
PlanEntries []PlanEntry `json:"plan_entries,omitempty"`
// PlanContent contains rich markdown plan content (for "agent_plan" type).
PlanContent string `json:"plan_content,omitempty"`
// --- Error fields (for "error" type) ---
// Error contains error message when Type is "error".
@@ -13,6 +13,7 @@ import (
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/service"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
)
@@ -315,6 +316,8 @@ func (s *Service) detectPushAndAssociatePR(
}
// resolveSessionRepo looks up the repository owner and name for a session.
// If provider info is missing but a local path exists, it detects from the git remote
// and backfills the DB record for future calls.
func (s *Service) resolveSessionRepo(ctx context.Context, sessionID string) (string, string) {
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil || session == nil || session.RepositoryID == "" {
@@ -328,9 +331,32 @@ func (s *Service) resolveSessionRepo(ctx context.Context, sessionID string) (str
if err != nil || repoObj == nil {
return "", ""
}
if repoObj.ProviderOwner == "" && repoObj.LocalPath != "" {
if p, o, n := service.ResolveGitRemoteProvider(repoObj.LocalPath); o != "" {
repoObj.Provider = p
repoObj.ProviderOwner = o
repoObj.ProviderName = n
go s.backfillRepoProvider(store, repoObj)
}
}
return repoObj.ProviderOwner, repoObj.ProviderName
}
// backfillRepoProvider persists auto-detected provider info to the DB.
func (s *Service) backfillRepoProvider(store repoStore, repo *models.Repository) {
if err := store.UpdateRepository(context.Background(), repo); err != nil {
s.logger.Warn("failed to backfill repository provider info",
zap.String("repository_id", repo.ID),
zap.Error(err))
} else {
s.logger.Info("backfilled repository provider info from git remote",
zap.String("repository_id", repo.ID),
zap.String("provider", repo.Provider),
zap.String("owner", repo.ProviderOwner),
zap.String("name", repo.ProviderName))
}
}
// ensureSessionPRWatch creates a PRWatch (pr_number=0) for a session's branch
// so the poller will search for a PR on GitHub. Runs as a background goroutine.
func (s *Service) ensureSessionPRWatch(ctx context.Context, taskID, sessionID, branch string) {
@@ -352,6 +378,8 @@ func (s *Service) ensureSessionPRWatch(ctx context.Context, taskID, sessionID, b
}
// resolveTaskRepo looks up the GitHub owner and repo name for a task's primary repository.
// If provider info is missing but a local path exists, it detects from the git remote
// and backfills the DB record for future calls.
func (s *Service) resolveTaskRepo(ctx context.Context, taskID string) (string, string) {
store, ok := s.repo.(repoStore)
if !ok {
@@ -365,6 +393,14 @@ func (s *Service) resolveTaskRepo(ctx context.Context, taskID string) (string, s
if err != nil || repoObj == nil {
return "", ""
}
if repoObj.ProviderOwner == "" && repoObj.LocalPath != "" {
if p, o, n := service.ResolveGitRemoteProvider(repoObj.LocalPath); o != "" {
repoObj.Provider = p
repoObj.ProviderOwner = o
repoObj.ProviderName = n
go s.backfillRepoProvider(store, repoObj)
}
}
if repoObj.Provider != "github" {
return "", ""
}
@@ -72,6 +72,9 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle
case "plan":
s.handleSessionTodosEvent(ctx, payload)
case "agent_plan":
s.handleAgentPlanEvent(ctx, payload)
case "permission_cancelled":
s.handlePermissionCancelledEvent(ctx, payload)
@@ -486,6 +489,24 @@ func (s *Service) handleCompleteStreamEvent(ctx context.Context, payload *lifecy
s.setSessionWaitingForInput(ctx, payload.TaskID, payload.SessionID, session)
}
// handleAgentPlanEvent handles agent_plan events from tool calls (e.g. ExitPlanMode)
// and creates a dedicated agent_plan message in the session.
func (s *Service) handleAgentPlanEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
if payload.SessionID == "" || payload.Data.PlanContent == "" || s.messageCreator == nil {
return
}
sessionID := payload.SessionID
if err := s.messageCreator.CreateSessionMessage(
ctx, payload.TaskID, payload.Data.PlanContent, sessionID,
string(models.MessageTypeAgentPlan), s.getActiveTurnID(sessionID), nil, false,
); err != nil {
s.logger.Error("failed to create agent plan message",
zap.String("task_id", payload.TaskID),
zap.String("session_id", sessionID),
zap.Error(err))
}
}
// publishAgentPlanIfPresent extracts plan_content from a complete event and creates
// a dedicated agent_plan message in the session.
func (s *Service) publishAgentPlanIfPresent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
@@ -113,6 +113,7 @@ type repoStore interface {
ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error)
CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error
GetRepository(ctx context.Context, id string) (*models.Repository, error)
UpdateRepository(ctx context.Context, repository *models.Repository) error
GetExecutorProfile(ctx context.Context, id string) (*models.ExecutorProfile, error)
GetWorkspace(ctx context.Context, id string) (*models.Workspace, error)
}
@@ -484,6 +484,105 @@ func collectRemoteBranches(remoteRefsRoot string, branchMap map[string]Branch) {
})
}
// readGitRemoteOriginURL reads the origin remote URL from a git repository's config.
// Handles both normal repos and worktrees by resolving the common git dir.
func readGitRemoteOriginURL(repoPath string) (string, error) {
gitDir, err := resolveGitDir(repoPath)
if err != nil {
return "", err
}
configDir := resolveCommonGitDir(gitDir)
configPath := filepath.Join(configDir, "config")
content, err := os.ReadFile(configPath)
if err != nil {
return "", err
}
return parseGitConfigOriginURL(string(content)), nil
}
// parseGitConfigOriginURL extracts the origin remote URL from git config content.
func parseGitConfigOriginURL(config string) string {
inOrigin := false
for line := range strings.SplitSeq(config, "\n") {
line = strings.TrimSpace(line)
if line == `[remote "origin"]` {
inOrigin = true
continue
}
if strings.HasPrefix(line, "[") {
inOrigin = false
continue
}
if inOrigin {
if url, ok := strings.CutPrefix(line, "url = "); ok {
return url
}
}
}
return ""
}
// ParseGitRemoteURL extracts provider, owner, and repo name from a git remote URL.
// Supports HTTPS (https://github.com/owner/repo.git), SSH (git@github.com:owner/repo.git),
// and ssh:// (ssh://git@github.com/owner/repo.git) formats.
// Returns empty strings for unrecognized URLs or non-GitHub providers.
func ParseGitRemoteURL(remoteURL string) (provider, owner, name string) {
remoteURL = strings.TrimSpace(remoteURL)
if remoteURL == "" {
return "", "", ""
}
host, path := splitRemoteURL(remoteURL)
if host == "" || path == "" {
return "", "", ""
}
if !strings.Contains(strings.ToLower(host), "github.com") {
return "", "", ""
}
path = strings.TrimSuffix(path, ".git")
path = strings.Trim(path, "/")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", "", ""
}
return "github", parts[0], parts[1]
}
// splitRemoteURL splits a git remote URL into host and path components.
func splitRemoteURL(remoteURL string) (host, path string) {
switch {
case strings.HasPrefix(remoteURL, "https://") || strings.HasPrefix(remoteURL, "http://"):
trimmed := strings.TrimPrefix(remoteURL, "https://")
trimmed = strings.TrimPrefix(trimmed, "http://")
host, path, _ = strings.Cut(trimmed, "/")
case strings.HasPrefix(remoteURL, "ssh://"):
trimmed := strings.TrimPrefix(remoteURL, "ssh://")
if _, after, ok := strings.Cut(trimmed, "@"); ok {
trimmed = after
}
host, path, _ = strings.Cut(trimmed, "/")
case strings.Contains(remoteURL, "@") && strings.Contains(remoteURL, ":"):
// git@github.com:owner/repo.git
_, afterAt, _ := strings.Cut(remoteURL, "@")
host, path, _ = strings.Cut(afterAt, ":")
}
return host, path
}
// ResolveGitRemoteProvider detects the provider, owner, and repo name from a
// local git repository's origin remote. Returns empty strings on any error.
func ResolveGitRemoteProvider(repoPath string) (provider, owner, name string) {
url, err := readGitRemoteOriginURL(repoPath)
if err != nil || url == "" {
return "", "", ""
}
return ParseGitRemoteURL(url)
}
func parsePackedRefs(refsRoot string, branchMap map[string]Branch) {
content, err := os.ReadFile(filepath.Join(refsRoot, "packed-refs"))
if err != nil {
@@ -272,6 +272,113 @@ func writeRef(t *testing.T, path string) {
}
}
func TestParseGitRemoteURL(t *testing.T) {
tests := []struct {
name string
url string
wantProvider string
wantOwner string
wantName string
}{
{"https with .git", "https://github.com/owner/repo.git", "github", "owner", "repo"},
{"https without .git", "https://github.com/owner/repo", "github", "owner", "repo"},
{"http", "http://github.com/owner/repo.git", "github", "owner", "repo"},
{"ssh colon", "git@github.com:owner/repo.git", "github", "owner", "repo"},
{"ssh colon no .git", "git@github.com:owner/repo", "github", "owner", "repo"},
{"ssh protocol", "ssh://git@github.com/owner/repo.git", "github", "owner", "repo"},
{"ssh protocol no .git", "ssh://git@github.com/owner/repo", "github", "owner", "repo"},
{"trailing slash", "https://github.com/owner/repo/", "github", "owner", "repo"},
{"empty", "", "", "", ""},
{"not github", "https://gitlab.com/owner/repo.git", "", "", ""},
{"no path", "https://github.com", "", "", ""},
{"no repo", "https://github.com/owner", "", "", ""},
{"malformed", "not-a-url", "", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider, owner, name := ParseGitRemoteURL(tt.url)
if provider != tt.wantProvider {
t.Errorf("provider = %q, want %q", provider, tt.wantProvider)
}
if owner != tt.wantOwner {
t.Errorf("owner = %q, want %q", owner, tt.wantOwner)
}
if name != tt.wantName {
t.Errorf("name = %q, want %q", name, tt.wantName)
}
})
}
}
func TestParseGitConfigOriginURL(t *testing.T) {
config := `[core]
repositoryformatversion = 0
[remote "origin"]
url = https://github.com/owner/repo.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
remote = origin
`
got := parseGitConfigOriginURL(config)
if got != "https://github.com/owner/repo.git" {
t.Errorf("parseGitConfigOriginURL = %q, want %q", got, "https://github.com/owner/repo.git")
}
}
func TestParseGitConfigOriginURL_NoOrigin(t *testing.T) {
config := `[core]
repositoryformatversion = 0
[remote "upstream"]
url = https://github.com/other/repo.git
`
got := parseGitConfigOriginURL(config)
if got != "" {
t.Errorf("parseGitConfigOriginURL = %q, want empty", got)
}
}
func TestResolveGitRemoteProvider(t *testing.T) {
// Create a temp git repo with an origin remote
dir := t.TempDir()
gitDir := filepath.Join(dir, ".git")
if err := os.MkdirAll(gitDir, 0o755); err != nil {
t.Fatal(err)
}
// Write HEAD
if err := os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
t.Fatal(err)
}
// Write config with origin remote
config := `[core]
repositoryformatversion = 0
[remote "origin"]
url = git@github.com:myorg/myrepo.git
fetch = +refs/heads/*:refs/remotes/origin/*
`
if err := os.WriteFile(filepath.Join(gitDir, "config"), []byte(config), 0o644); err != nil {
t.Fatal(err)
}
provider, owner, name := ResolveGitRemoteProvider(dir)
if provider != "github" {
t.Errorf("provider = %q, want %q", provider, "github")
}
if owner != "myorg" {
t.Errorf("owner = %q, want %q", owner, "myorg")
}
if name != "myrepo" {
t.Errorf("name = %q, want %q", name, "myrepo")
}
}
func TestResolveGitRemoteProvider_NonGitDir(t *testing.T) {
dir := t.TempDir()
provider, owner, name := ResolveGitRemoteProvider(dir)
if provider != "" || owner != "" || name != "" {
t.Errorf("expected empty for non-git dir, got %q %q %q", provider, owner, name)
}
}
func newDiscoveryService(t *testing.T, root string) *Service {
t.Helper()
tmpDir := t.TempDir()
@@ -230,6 +230,15 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq
DevScript: req.DevScript,
}
// Auto-detect GitHub provider info from git remote if not provided
if repository.Provider == "" && repository.LocalPath != "" {
if p, o, n := ResolveGitRemoteProvider(repository.LocalPath); p != "" {
repository.Provider = p
repository.ProviderOwner = o
repository.ProviderName = n
}
}
if err := s.repoEntities.CreateRepository(ctx, repository); err != nil {
s.logger.Error("failed to create repository", zap.Error(err))
return nil, err
@@ -73,7 +73,7 @@ func (s *Service) CompleteTurn(ctx context.Context, turnID string) error {
// Fetch the completed turn to get the completed_at timestamp
turn, err := s.turns.GetTurn(ctx, turnID)
if err != nil {
s.logger.Warn("failed to refetch completed turn", zap.String("turn_id", turnID), zap.Error(err))
s.logger.Debug("failed to refetch completed turn", zap.String("turn_id", turnID), zap.Error(err))
// Turn was likely deleted (task deletion race), skip publishing
return nil
}
@@ -0,0 +1,292 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { IconGitBranch, IconCopy, IconCheck, IconPencil } from "@tabler/icons-react";
import { Input } from "@kandev/ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { formatUserHomePath } from "@/lib/utils";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
/** Copy button with check/copy icon toggle */
function CopyIconButton({
copied,
onClick,
className,
}: {
copied: boolean;
onClick: (e: React.MouseEvent) => void;
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={`cursor-pointer ${className ?? ""}`}
aria-label={copied ? "Copied" : "Copy to clipboard"}
>
{copied ? (
<IconCheck className="h-3 w-3 text-green-500" />
) : (
<IconCopy className="h-3 w-3 text-muted-foreground hover:text-foreground" />
)}
</button>
);
}
/** Copiable path row used in the branch popover */
function PathRow({ label, path }: { label: string; path: string }) {
const { copied, copy: handleCopy } = useCopyToClipboard();
return (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="relative group/row overflow-hidden">
<div className="text-xs text-muted-foreground bg-muted/50 px-2 py-1.5 pr-9 rounded-sm select-text cursor-text whitespace-nowrap">
{formatUserHomePath(path)}
</div>
<CopyIconButton
copied={copied}
onClick={(e) => {
e.stopPropagation();
handleCopy(formatUserHomePath(path));
}}
className="absolute right-1 top-1 p-1 rounded bg-background/80 backdrop-blur-sm hover:bg-background transition-all shadow-sm"
/>
</div>
</div>
);
}
/** Inline branch rename input */
function BranchRenameInput({
editValue,
onEditValueChange,
onConfirm,
onCancel,
isRenaming,
}: {
editValue: string;
onEditValueChange: (v: string) => void;
onConfirm: () => void;
onCancel: () => void;
isRenaming: boolean;
}) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm();
} else if (e.key === "Escape") {
onCancel();
}
},
[onConfirm, onCancel],
);
return (
<div className="flex items-center gap-1.5 rounded-md px-1 h-7 bg-muted/40 min-w-0 max-w-full">
<IconGitBranch className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<Input
ref={inputRef}
value={editValue}
onChange={(e) => onEditValueChange(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={onConfirm}
disabled={isRenaming}
className="h-5 text-xs px-1 py-0 w-32 bg-background border-primary/50"
/>
</div>
);
}
/** Hook for branch rename editing state */
function useBranchRenameEdit(
displayBranch: string | undefined,
onRenameBranch: ((newName: string) => Promise<void>) | undefined,
) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(displayBranch ?? "");
const [isRenaming, setIsRenaming] = useState(false);
useEffect(() => {
if (!isEditing) setEditValue(displayBranch ?? "");
}, [displayBranch, isEditing]);
const handleStartEdit = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsEditing(true);
}, []);
const handleCancelEdit = useCallback(() => {
setIsEditing(false);
setEditValue(displayBranch ?? "");
}, [displayBranch]);
const handleConfirmRename = useCallback(async () => {
const trimmed = editValue.trim();
if (!onRenameBranch || !trimmed || trimmed === displayBranch?.trim() || isRenaming) {
handleCancelEdit();
return;
}
setIsRenaming(true);
try {
await onRenameBranch(trimmed);
setIsEditing(false);
} catch {
// Error is handled by onRenameBranch (shows toast), keep edit mode open
} finally {
setIsRenaming(false);
}
}, [onRenameBranch, editValue, displayBranch, handleCancelEdit, isRenaming]);
return {
isEditing,
editValue,
setEditValue,
isRenaming,
handleStartEdit,
handleCancelEdit,
handleConfirmRename,
};
}
function BranchPillTrigger({
displayBranch,
copiedBranch,
onCopyBranch,
onStartEdit,
canRename,
}: {
displayBranch: string;
copiedBranch: boolean;
onCopyBranch: (text: string) => void;
onStartEdit: (e: React.MouseEvent) => void;
canRename: boolean;
}) {
return (
<div className="group flex items-center gap-1.5 rounded-md px-2 h-7 bg-muted/40 hover:bg-muted/60 cursor-pointer transition-colors min-w-0 max-w-full">
<IconGitBranch className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground truncate">{displayBranch}</span>
{canRename && (
<button
type="button"
onClick={onStartEdit}
className="opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer p-0.5 hover:bg-muted rounded"
aria-label="Rename branch"
title="Rename branch"
>
<IconPencil className="h-3 w-3 text-muted-foreground" />
</button>
)}
<CopyIconButton
copied={copiedBranch}
onClick={(e) => {
e.stopPropagation();
onCopyBranch(displayBranch);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer ml-0.5"
/>
</div>
);
}
export function BranchPathPopover({
displayBranch,
repositoryPath,
worktreePath,
onRenameBranch,
}: {
displayBranch?: string;
repositoryPath?: string | null;
worktreePath?: string | null;
onRenameBranch?: (newName: string) => Promise<void>;
}) {
const { copied: copiedBranch, copy: handleCopyBranch } = useCopyToClipboard();
const [popoverOpen, setPopoverOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const recentlyClosedRef = useRef(false);
const {
isEditing,
editValue,
setEditValue,
isRenaming,
handleStartEdit: startEdit,
handleCancelEdit,
handleConfirmRename,
} = useBranchRenameEdit(displayBranch, onRenameBranch);
const handleStartEdit = useCallback(
(e: React.MouseEvent) => {
startEdit(e);
setPopoverOpen(false);
},
[startEdit],
);
const handlePopoverOpenChange = useCallback((open: boolean) => {
setPopoverOpen(open);
if (!open) {
recentlyClosedRef.current = true;
setTooltipOpen(false);
setTimeout(() => {
recentlyClosedRef.current = false;
}, 200);
}
}, []);
const handleTooltipOpenChange = useCallback(
(open: boolean) => {
if (open && (popoverOpen || recentlyClosedRef.current)) return;
setTooltipOpen(open);
},
[popoverOpen],
);
if (!displayBranch) return null;
if (isEditing) {
return (
<BranchRenameInput
editValue={editValue}
onEditValueChange={setEditValue}
onConfirm={handleConfirmRename}
onCancel={handleCancelEdit}
isRenaming={isRenaming}
/>
);
}
return (
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange}>
<Tooltip open={tooltipOpen} onOpenChange={handleTooltipOpenChange}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<BranchPillTrigger
displayBranch={displayBranch}
copiedBranch={copiedBranch}
onCopyBranch={handleCopyBranch}
onStartEdit={handleStartEdit}
canRename={!!onRenameBranch}
/>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right">
{onRenameBranch ? "Click to see paths, or click pencil to rename" : "Click to see paths"}
</TooltipContent>
</Tooltip>
<PopoverContent side="bottom" sideOffset={5} className="p-0 w-auto max-w-[600px] gap-1">
<div className="px-2 pt-1 pb-2 space-y-1.5">
{repositoryPath && <PathRow label="Repository" path={repositoryPath} />}
{worktreePath && <PathRow label="Worktree" path={worktreePath} />}
</div>
</PopoverContent>
</Popover>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import { IconBrain, IconGitMerge, IconX } from "@tabler/icons-react";
import { IconGitMerge, IconX } from "@tabler/icons-react";
import { TodoIndicator } from "./todo-indicator";
import { getWebSocketClient } from "@/lib/ws/connection";
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
@@ -309,20 +309,6 @@ function SessionStatusBar({ todoItems }: { todoItems: TodoDisplayItem[] }) {
);
}
function AgentModeIndicator({ sessionId }: { sessionId: string | null }) {
const modeState = useAppStore((state) =>
sessionId ? state.sessionMode.bySessionId[sessionId] : undefined,
);
const modeId = modeState?.currentModeId;
if (!modeId || modeId === "default") return null;
return (
<div className="flex items-center gap-1.5 px-3 py-1 text-xs text-muted-foreground">
<IconBrain className="h-3 w-3" />
<span className="capitalize">{modeId} mode</span>
</div>
);
}
type ChatInputAreaProps = {
chatInputRef: React.RefObject<ChatInputContainerHandle | null>;
clarificationKey: number;
@@ -393,7 +379,6 @@ export function ChatInputArea({
return (
<div className="bg-card flex-shrink-0 px-2 pb-2 pt-1">
<SessionStatusBar todoItems={todoItems} />
<AgentModeIndicator sessionId={resolvedSessionId} />
{taskId && <PRMergedBanner key={taskId} taskId={taskId} />}
<ChatInputContainer
ref={chatInputRef}
@@ -34,7 +34,6 @@ import { TokenUsageDisplay } from "@/components/task/chat/token-usage-display";
import { SessionsDropdown } from "@/components/task/sessions-dropdown";
import { ModelSelector } from "@/components/task/model-selector";
import { ModeSelector } from "@/components/task/mode-selector";
import { ConfigOptionsDisplay } from "@/components/task/config-options-display";
import { ContextPopover } from "./context-popover";
import type { ContextFile } from "@/lib/state/context-files-store";
@@ -364,9 +363,7 @@ function ToolbarRightSection({
/>
)}
<TokenUsageDisplay sessionId={sessionId} />
<ModeSelector sessionId={sessionId} />
<ModelSelector sessionId={sessionId} />
<ConfigOptionsDisplay sessionId={sessionId} />
{/* AuthMethodsIndicator hidden — UX needs rethinking */}
{showEnhance && (
<EnhancePromptButton
@@ -391,6 +388,25 @@ function ToolbarRightSection({
);
}
function AttachFilesButton({ 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"
onClick={onClick}
>
<IconPaperclip className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Attach files</TooltipContent>
</Tooltip>
);
}
export const ChatInputToolbar = memo(function ChatInputToolbar({
planModeEnabled,
planModeAvailable = true,
@@ -432,22 +448,7 @@ export const ChatInputToolbar = memo(function ChatInputToolbar({
<McpIndicator mcpServers={mcpServers} />
{onAttachFiles && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40"
onClick={onAttachFiles}
>
<IconPaperclip className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Attach files</TooltipContent>
</Tooltip>
)}
{onAttachFiles && <AttachFilesButton onClick={onAttachFiles} />}
<ContextPopover
open={contextPopoverOpen}
@@ -472,6 +473,7 @@ export const ChatInputToolbar = memo(function ChatInputToolbar({
contextFiles={contextFiles}
onToggleFile={onToggleFile ?? (() => {})}
/>
<ModeSelector sessionId={sessionId} />
</div>
<div className="flex-1" />
@@ -1,49 +0,0 @@
"use client";
import { memo } from "react";
import { IconSettings } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { useAppStore } from "@/components/state-provider";
type ConfigOptionsDisplayProps = {
sessionId: string | null;
};
export const ConfigOptionsDisplay = memo(function ConfigOptionsDisplay({
sessionId,
}: ConfigOptionsDisplayProps) {
const configOptions = useAppStore((state) => {
if (!sessionId) return undefined;
return state.sessionModels.bySessionId[sessionId]?.configOptions;
});
if (!sessionId || !configOptions || configOptions.length === 0) {
return null;
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 cursor-pointer hover:bg-muted/40"
>
<IconSettings className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" side="top" className="w-64 p-3">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Config Options</div>
{configOptions.map((option) => (
<div key={option.id} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{option.name}</span>
<span className="font-mono">{option.currentValue}</span>
</div>
))}
</div>
</PopoverContent>
</Popover>
);
});
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import {
IconLayout,
IconCheck,
@@ -212,6 +212,7 @@ export function LayoutPresetSelector() {
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const recentlyClosedRef = useRef(false);
const applyBuiltInPreset = useDockviewStore((s) => s.applyBuiltInPreset);
const applyCustomLayout = useDockviewStore((s) => s.applyCustomLayout);
const savedLayouts = useAppStore((s) => s.userSettings.savedLayouts);
@@ -235,10 +236,29 @@ export function LayoutPresetSelector() {
[savedLayouts],
);
const handleDropdownOpenChange = useCallback((open: boolean) => {
setDropdownOpen(open);
if (!open) {
recentlyClosedRef.current = true;
setTooltipOpen(false);
setTimeout(() => {
recentlyClosedRef.current = false;
}, 200);
}
}, []);
const handleTooltipOpenChange = useCallback(
(open: boolean) => {
if (open && (dropdownOpen || recentlyClosedRef.current)) return;
setTooltipOpen(open);
},
[dropdownOpen],
);
return (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<Tooltip open={dropdownOpen ? false : tooltipOpen} onOpenChange={setTooltipOpen}>
<DropdownMenu open={dropdownOpen} onOpenChange={handleDropdownOpenChange}>
<Tooltip open={tooltipOpen} onOpenChange={handleTooltipOpenChange}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="cursor-pointer px-2">
+45 -14
View File
@@ -1,14 +1,16 @@
"use client";
import { memo, useCallback } from "react";
import { memo, useCallback, useRef, useState } from "react";
import { IconChevronDown } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import { setSessionMode } from "@/lib/api/domains/session-api";
@@ -21,6 +23,10 @@ export const ModeSelector = memo(function ModeSelector({ sessionId }: ModeSelect
sessionId ? state.sessionMode.bySessionId[sessionId] : undefined,
);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const recentlyClosedRef = useRef(false);
const handleModeChange = useCallback(
async (modeId: string) => {
if (!sessionId) return;
@@ -33,6 +39,25 @@ export const ModeSelector = memo(function ModeSelector({ sessionId }: ModeSelect
[sessionId],
);
const handleDropdownOpenChange = useCallback((open: boolean) => {
setDropdownOpen(open);
if (!open) {
recentlyClosedRef.current = true;
setTooltipOpen(false);
setTimeout(() => {
recentlyClosedRef.current = false;
}, 200);
}
}, []);
const handleTooltipOpenChange = useCallback(
(open: boolean) => {
if (open && (dropdownOpen || recentlyClosedRef.current)) return;
setTooltipOpen(open);
},
[dropdownOpen],
);
if (
!sessionId ||
!modeState ||
@@ -46,23 +71,29 @@ export const ModeSelector = memo(function ModeSelector({ sessionId }: ModeSelect
const displayName = currentMode?.name || modeState.currentModeId || "Mode";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 cursor-pointer hover:bg-muted/40 whitespace-nowrap"
>
<span className="text-xs">{displayName}</span>
<IconChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" className="min-w-[280px]">
<DropdownMenu open={dropdownOpen} onOpenChange={handleDropdownOpenChange}>
<Tooltip open={tooltipOpen} onOpenChange={handleTooltipOpenChange}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 cursor-pointer hover:bg-muted/40 whitespace-nowrap"
>
<span className="text-xs">{displayName}</span>
<IconChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">Agent permission mode</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" side="top" className="min-w-[280px]">
<DropdownMenuLabel>Available Modes</DropdownMenuLabel>
{modeState.availableModes.map((mode) => (
<DropdownMenuItem
key={mode.id}
onClick={() => handleModeChange(mode.id)}
className={mode.id === modeState.currentModeId ? "bg-muted" : ""}
className={`cursor-pointer ${mode.id === modeState.currentModeId ? "bg-muted" : ""}`}
>
<div>
<div>{mode.name}</div>
+4 -258
View File
@@ -1,18 +1,9 @@
"use client";
import { memo, useState, useRef, useEffect, useCallback } from "react";
import { memo, useCallback } from "react";
import Link from "next/link";
import {
IconBug,
IconCopy,
IconGitBranch,
IconCheck,
IconHome,
IconSettings,
IconPencil,
} from "@tabler/icons-react";
import { IconBug, IconHome, IconSettings } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import {
Breadcrumb,
BreadcrumbItem,
@@ -22,11 +13,10 @@ import {
BreadcrumbSeparator,
} from "@kandev/ui/breadcrumb";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { CommitStatBadge } from "@/components/diff-stat";
import { useSessionGit } from "@/hooks/domains/session/use-session-git";
import { formatUserHomePath } from "@/lib/utils";
import { EditorsMenu } from "@/components/task/editors-menu";
import { BranchPathPopover } from "@/components/task/branch-path-popover";
import { LayoutPresetSelector } from "@/components/task/layout-preset-selector";
import { DocumentControls } from "@/components/task/document/document-controls";
import { VcsSplitButton } from "@/components/vcs-split-button";
@@ -155,250 +145,6 @@ const TaskTopBar = memo(function TaskTopBar({
);
});
/** Copies text to clipboard with a brief "copied" state */
function useCopyToClipboard(): [boolean, (text: string) => void] {
const [copied, setCopied] = useState(false);
const handleCopy = (text: string) => {
navigator.clipboard?.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 500);
};
return [copied, handleCopy];
}
/** Copy button with check/copy icon toggle */
function CopyIconButton({
copied,
onClick,
className,
}: {
copied: boolean;
onClick: (e: React.MouseEvent) => void;
className?: string;
}) {
return (
<button type="button" onClick={onClick} className={className}>
{copied ? (
<IconCheck className="h-3 w-3 text-green-500" />
) : (
<IconCopy className="h-3 w-3 text-muted-foreground hover:text-foreground" />
)}
</button>
);
}
/** Copiable path row used in the branch popover */
function PathRow({ label, path }: { label: string; path: string }) {
const [copied, handleCopy] = useCopyToClipboard();
return (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="relative group/row overflow-hidden">
<div className="text-xs text-muted-foreground bg-muted/50 px-2 py-1.5 pr-9 rounded-sm select-text cursor-text whitespace-nowrap">
{formatUserHomePath(path)}
</div>
<CopyIconButton
copied={copied}
onClick={(e) => {
e.stopPropagation();
handleCopy(formatUserHomePath(path));
}}
className="absolute right-1 top-1 p-1 rounded bg-background/80 backdrop-blur-sm hover:bg-background transition-all shadow-sm"
/>
</div>
</div>
);
}
/** Inline branch rename input */
function BranchRenameInput({
editValue,
onEditValueChange,
onConfirm,
onCancel,
isRenaming,
}: {
editValue: string;
onEditValueChange: (v: string) => void;
onConfirm: () => void;
onCancel: () => void;
isRenaming: boolean;
}) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm();
} else if (e.key === "Escape") {
onCancel();
}
},
[onConfirm, onCancel],
);
return (
<div className="flex items-center gap-1.5 rounded-md px-1 h-7 bg-muted/40 min-w-0 max-w-full">
<IconGitBranch className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<Input
ref={inputRef}
value={editValue}
onChange={(e) => onEditValueChange(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={onConfirm}
disabled={isRenaming}
className="h-5 text-xs px-1 py-0 w-32 bg-background border-primary/50"
/>
</div>
);
}
/** Hook for branch rename editing state */
function useBranchRenameEdit(
displayBranch: string | undefined,
onRenameBranch: ((newName: string) => Promise<void>) | undefined,
) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(displayBranch ?? "");
const [isRenaming, setIsRenaming] = useState(false);
useEffect(() => {
if (!isEditing) setEditValue(displayBranch ?? "");
}, [displayBranch, isEditing]);
const handleStartEdit = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsEditing(true);
}, []);
const handleCancelEdit = useCallback(() => {
setIsEditing(false);
setEditValue(displayBranch ?? "");
}, [displayBranch]);
const handleConfirmRename = useCallback(async () => {
const trimmed = editValue.trim();
if (!onRenameBranch || !trimmed || trimmed === displayBranch?.trim() || isRenaming) {
handleCancelEdit();
return;
}
setIsRenaming(true);
try {
await onRenameBranch(trimmed);
setIsEditing(false);
} catch {
// Error is handled by onRenameBranch (shows toast), keep edit mode open
} finally {
setIsRenaming(false);
}
}, [onRenameBranch, editValue, displayBranch, handleCancelEdit, isRenaming]);
return {
isEditing,
editValue,
setEditValue,
isRenaming,
handleStartEdit,
handleCancelEdit,
handleConfirmRename,
};
}
function BranchPathPopover({
displayBranch,
repositoryPath,
worktreePath,
onRenameBranch,
}: {
displayBranch?: string;
repositoryPath?: string | null;
worktreePath?: string | null;
onRenameBranch?: (newName: string) => Promise<void>;
}) {
const [copiedBranch, handleCopyBranch] = useCopyToClipboard();
const [popoverOpen, setPopoverOpen] = useState(false);
const {
isEditing,
editValue,
setEditValue,
isRenaming,
handleStartEdit: startEdit,
handleCancelEdit,
handleConfirmRename,
} = useBranchRenameEdit(displayBranch, onRenameBranch);
const handleStartEdit = useCallback(
(e: React.MouseEvent) => {
startEdit(e);
setPopoverOpen(false);
},
[startEdit],
);
if (!displayBranch) return null;
if (isEditing) {
return (
<BranchRenameInput
editValue={editValue}
onEditValueChange={setEditValue}
onConfirm={handleConfirmRename}
onCancel={handleCancelEdit}
isRenaming={isRenaming}
/>
);
}
return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<div className="group flex items-center gap-1.5 rounded-md px-2 h-7 bg-muted/40 hover:bg-muted/60 cursor-pointer transition-colors min-w-0 max-w-full">
<IconGitBranch className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground truncate">{displayBranch}</span>
{onRenameBranch && (
<button
type="button"
onClick={handleStartEdit}
className="opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer p-0.5 hover:bg-muted rounded"
aria-label="Rename branch"
title="Rename branch"
>
<IconPencil className="h-3 w-3 text-muted-foreground" />
</button>
)}
<CopyIconButton
copied={copiedBranch}
onClick={(e) => {
e.stopPropagation();
handleCopyBranch(displayBranch);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer ml-0.5"
/>
</div>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right">
{onRenameBranch ? "Click to see paths, or click pencil to rename" : "Click to see paths"}
</TooltipContent>
</Tooltip>
<PopoverContent side="bottom" sideOffset={5} className="p-0 w-auto max-w-[600px] gap-1">
<div className="px-2 pt-1 pb-2 space-y-1.5">
{repositoryPath && <PathRow label="Repository" path={repositoryPath} />}
{worktreePath && <PathRow label="Worktree" path={worktreePath} />}
</div>
</PopoverContent>
</Popover>
);
}
/** Left section: breadcrumbs, branch pill */
function TopBarLeft({
taskId,
@@ -441,7 +187,7 @@ function TopBarLeft({
<BreadcrumbLink asChild>
<Link
href="/"
className="text-muted-foreground hover:text-foreground transition-colors"
className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
>
<IconHome className="h-4 w-4" />
</Link>