diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go index 361f4403d9..c835374e36 100644 --- a/apps/backend/internal/agent/lifecycle/event_types.go +++ b/apps/backend/internal/agent/lifecycle/event_types.go @@ -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). diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index 080a0a95c8..ea8f036a21 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -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 diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go index 81f61ab071..e7ef5516ef 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -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 diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go index d11ecae2ca..3cb0fe5514 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go @@ -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) { diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go index 1c2b3cec78..7245abfc69 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go @@ -188,8 +188,19 @@ func parseFileList(output string) []string { // parseShellOutput parses ACP's XML-like shell output format. // Format: "...N........." +// 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 tag if present). func parseShellOutput(output string) (exitCode int, stdout, stderr string) { + hasXMLTags := strings.Contains(output, "") || + strings.Contains(output, "") || + strings.Contains(output, "") + + 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, ""); start != -1 { start += len("") @@ -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) diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go index e50c8006ef..ef4682ec5c 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go @@ -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": "\n0\n\n\nfile1.txt\nfile2.txt\n", + }, + }) + + 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 { diff --git a/apps/backend/internal/agentctl/server/api/agent.go b/apps/backend/internal/agentctl/server/api/agent.go index 9350393a5a..24f42e3a7b 100644 --- a/apps/backend/internal/agentctl/server/api/agent.go +++ b/apps/backend/internal/agentctl/server/api/agent.go @@ -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. diff --git a/apps/backend/internal/agentctl/server/api/control_server.go b/apps/backend/internal/agentctl/server/api/control_server.go index a5ffe3d41c..eae59d862c 100644 --- a/apps/backend/internal/agentctl/server/api/control_server.go +++ b/apps/backend/internal/agentctl/server/api/control_server.go @@ -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{ diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index d0d66302e0..7e56c1083c 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -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() diff --git a/apps/backend/internal/agentctl/types/streams/agent.go b/apps/backend/internal/agentctl/types/streams/agent.go index cf08f28e61..4f295a8153 100644 --- a/apps/backend/internal/agentctl/types/streams/agent.go +++ b/apps/backend/internal/agentctl/types/streams/agent.go @@ -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". diff --git a/apps/backend/internal/orchestrator/event_handlers_github.go b/apps/backend/internal/orchestrator/event_handlers_github.go index de21cb56c1..90ad2576f6 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github.go +++ b/apps/backend/internal/orchestrator/event_handlers_github.go @@ -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 "", "" } diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index 6f07d575f1..26645d0f4e 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -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) { diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 0408340082..392214448c 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -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) } diff --git a/apps/backend/internal/task/service/repository_discovery.go b/apps/backend/internal/task/service/repository_discovery.go index c7d49e5615..5f51cbfec0 100644 --- a/apps/backend/internal/task/service/repository_discovery.go +++ b/apps/backend/internal/task/service/repository_discovery.go @@ -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 { diff --git a/apps/backend/internal/task/service/repository_discovery_test.go b/apps/backend/internal/task/service/repository_discovery_test.go index add28acbdc..80554675fc 100644 --- a/apps/backend/internal/task/service/repository_discovery_test.go +++ b/apps/backend/internal/task/service/repository_discovery_test.go @@ -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() diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 58aac5fabd..7efb4377af 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -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 diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index 90bb3e77e4..4804addb2d 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -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 } diff --git a/apps/web/components/task/branch-path-popover.tsx b/apps/web/components/task/branch-path-popover.tsx new file mode 100644 index 0000000000..8efdca4af1 --- /dev/null +++ b/apps/web/components/task/branch-path-popover.tsx @@ -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 ( + + ); +} + +/** Copiable path row used in the branch popover */ +function PathRow({ label, path }: { label: string; path: string }) { + const { copied, copy: handleCopy } = useCopyToClipboard(); + return ( +
+
{label}
+
+
+ {formatUserHomePath(path)} +
+ { + 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" + /> +
+
+ ); +} + +/** 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(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 ( +
+ + 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" + /> +
+ ); +} + +/** Hook for branch rename editing state */ +function useBranchRenameEdit( + displayBranch: string | undefined, + onRenameBranch: ((newName: string) => Promise) | 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 ( +
+ + {displayBranch} + {canRename && ( + + )} + { + e.stopPropagation(); + onCopyBranch(displayBranch); + }} + className="opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer ml-0.5" + /> +
+ ); +} + +export function BranchPathPopover({ + displayBranch, + repositoryPath, + worktreePath, + onRenameBranch, +}: { + displayBranch?: string; + repositoryPath?: string | null; + worktreePath?: string | null; + onRenameBranch?: (newName: string) => Promise; +}) { + 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 ( + + ); + } + + return ( + + + + + + + + + {onRenameBranch ? "Click to see paths, or click pencil to rename" : "Click to see paths"} + + + +
+ {repositoryPath && } + {worktreePath && } +
+
+
+ ); +} diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 291b150805..3f63d8cfa0 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -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 ( -
- - {modeId} mode -
- ); -} - type ChatInputAreaProps = { chatInputRef: React.RefObject; clarificationKey: number; @@ -393,7 +379,6 @@ export function ChatInputArea({ return (
- {taskId && } )} - - {/* AuthMethodsIndicator hidden — UX needs rethinking */} {showEnhance && ( void }) { + return ( + + + + + Attach files + + ); +} + export const ChatInputToolbar = memo(function ChatInputToolbar({ planModeEnabled, planModeAvailable = true, @@ -432,22 +448,7 @@ export const ChatInputToolbar = memo(function ChatInputToolbar({ - {onAttachFiles && ( - - - - - Attach files - - )} + {onAttachFiles && } {})} /> +
diff --git a/apps/web/components/task/config-options-display.tsx b/apps/web/components/task/config-options-display.tsx deleted file mode 100644 index e270f91a4c..0000000000 --- a/apps/web/components/task/config-options-display.tsx +++ /dev/null @@ -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 ( - - - - - -
-
Config Options
- {configOptions.map((option) => ( -
- {option.name} - {option.currentValue} -
- ))} -
-
-
- ); -}); diff --git a/apps/web/components/task/layout-preset-selector.tsx b/apps/web/components/task/layout-preset-selector.tsx index ca1ae4e4eb..40dd31b2dc 100644 --- a/apps/web/components/task/layout-preset-selector.tsx +++ b/apps/web/components/task/layout-preset-selector.tsx @@ -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 ( <> - - + + - - + + + + + + + + Agent permission mode + + + Available Modes {modeState.availableModes.map((mode) => ( handleModeChange(mode.id)} - className={mode.id === modeState.currentModeId ? "bg-muted" : ""} + className={`cursor-pointer ${mode.id === modeState.currentModeId ? "bg-muted" : ""}`} >
{mode.name}
diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 1c275c0525..99fb5afca2 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -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 ( - - ); -} - -/** Copiable path row used in the branch popover */ -function PathRow({ label, path }: { label: string; path: string }) { - const [copied, handleCopy] = useCopyToClipboard(); - return ( -
-
{label}
-
-
- {formatUserHomePath(path)} -
- { - 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" - /> -
-
- ); -} - -/** 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(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 ( -
- - 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" - /> -
- ); -} - -/** Hook for branch rename editing state */ -function useBranchRenameEdit( - displayBranch: string | undefined, - onRenameBranch: ((newName: string) => Promise) | 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; -}) { - 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 ( - - ); - } - - return ( - - - - -
- - {displayBranch} - {onRenameBranch && ( - - )} - { - e.stopPropagation(); - handleCopyBranch(displayBranch); - }} - className="opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer ml-0.5" - /> -
-
-
- - {onRenameBranch ? "Click to see paths, or click pencil to rename" : "Click to see paths"} - -
- -
- {repositoryPath && } - {worktreePath && } -
-
-
- ); -} - /** Left section: breadcrumbs, branch pill */ function TopBarLeft({ taskId, @@ -441,7 +187,7 @@ function TopBarLeft({