fix: resolve acp chat ux issues with permissions, plans, and tool states (#428)
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user