diff --git a/apps/backend/cmd/agentctl/main.go b/apps/backend/cmd/agentctl/main.go index 856b7cf754..9a7f3aff8a 100644 --- a/apps/backend/cmd/agentctl/main.go +++ b/apps/backend/cmd/agentctl/main.go @@ -92,7 +92,7 @@ func run(cfg *config.Config, log *logger.Logger) { mcpBackendClient := mcpserver.NewChannelBackendClient(instLog) // Create MCP server using the channel-based backend client - mcpSrv := mcpserver.New(mcpBackendClient, instCfg.SessionID, instCfg.Port, instLog, cfg.McpLogFile, instCfg.DisableAskQuestion, instCfg.McpMode) + mcpSrv := mcpserver.New(mcpBackendClient, instCfg.SessionID, instCfg.TaskID, instCfg.Port, instLog, cfg.McpLogFile, instCfg.DisableAskQuestion, instCfg.McpMode) instLog.Info("MCP server enabled (channel-based)", zap.String("session_id", instCfg.SessionID)) diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites_operations.go b/apps/backend/internal/agent/lifecycle/executor_sprites_operations.go index ee86b0b5b9..3805fb08ce 100644 --- a/apps/backend/internal/agent/lifecycle/executor_sprites_operations.go +++ b/apps/backend/internal/agent/lifecycle/executor_sprites_operations.go @@ -226,6 +226,7 @@ func (r *SpritesExecutor) createAgentInstance( ID: req.InstanceID, WorkspacePath: spritesWorkspacePath, SessionID: req.SessionID, + TaskID: req.TaskID, Protocol: req.Protocol, AgentType: agentTypeFromReq(req), McpServers: req.McpServers, diff --git a/apps/backend/internal/agent/lifecycle/executor_standalone.go b/apps/backend/internal/agent/lifecycle/executor_standalone.go index f190469df5..c4ee35f084 100644 --- a/apps/backend/internal/agent/lifecycle/executor_standalone.go +++ b/apps/backend/internal/agent/lifecycle/executor_standalone.go @@ -106,6 +106,7 @@ func (r *StandaloneExecutor) CreateInstance(ctx context.Context, req *ExecutorCr AutoStart: false, McpServers: req.McpServers, SessionID: req.SessionID, + TaskID: req.TaskID, DisableAskQuestion: disableAskQuestion, AssumeMcpSse: assumeMcpSse, McpMode: req.McpMode, diff --git a/apps/backend/internal/agentctl/client/control.go b/apps/backend/internal/agentctl/client/control.go index b16bcd0f1f..d5191e016b 100644 --- a/apps/backend/internal/agentctl/client/control.go +++ b/apps/backend/internal/agentctl/client/control.go @@ -44,6 +44,7 @@ type CreateInstanceRequest struct { AutoStart bool `json:"auto_start,omitempty"` McpServers []McpServerConfig `json:"mcp_servers,omitempty"` SessionID string `json:"session_id,omitempty"` // Task session ID for MCP tool calls + TaskID string `json:"task_id,omitempty"` // Task ID for MCP plan tool calls (server-side injection) DisableAskQuestion bool `json:"disable_ask_question,omitempty"` // Disable ask_user_question MCP tool (TUI agents) AssumeMcpSse bool `json:"assume_mcp_sse,omitempty"` // Assume agent supports SSE MCP servers McpMode string `json:"mcp_mode,omitempty"` // MCP tool mode: "task" (default) or "config" diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 04bff30db0..c5f3aeeae2 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -144,6 +144,9 @@ type InstanceConfig struct { // SessionID is the session ID for this agent instance (used in MCP tool calls) SessionID string + // TaskID is the task ID for this agent instance (used in MCP plan tool calls) + TaskID string + // ContinueCommand is the command template for follow-up prompts in one-shot agents. // When set, the adapter spawns a new process per prompt using this command for // continuation (thread ID appended at runtime). Only used by Amp. @@ -260,6 +263,9 @@ func applyOverrides(cfg *InstanceConfig, overrides *InstanceOverrides) { if overrides.SessionID != "" { cfg.SessionID = overrides.SessionID } + if overrides.TaskID != "" { + cfg.TaskID = overrides.TaskID + } if overrides.DisableAskQuestion { cfg.DisableAskQuestion = true } @@ -282,6 +288,7 @@ type InstanceOverrides struct { AgentType string McpServers []McpServerConfig SessionID string + TaskID string DisableAskQuestion bool AssumeMcpSse bool McpMode string diff --git a/apps/backend/internal/agentctl/server/instance/instance.go b/apps/backend/internal/agentctl/server/instance/instance.go index 1b2cd64508..46dd1dd219 100644 --- a/apps/backend/internal/agentctl/server/instance/instance.go +++ b/apps/backend/internal/agentctl/server/instance/instance.go @@ -84,6 +84,9 @@ type CreateRequest struct { // SessionID is the task session ID for MCP tool calls (used by ask_user_question). SessionID string `json:"session_id,omitempty"` + // TaskID is the task ID for MCP plan tool calls (server-side injection). + TaskID string `json:"task_id,omitempty"` + // DisableAskQuestion disables the ask_user_question MCP tool (for TUI agents). DisableAskQuestion bool `json:"disable_ask_question,omitempty"` diff --git a/apps/backend/internal/agentctl/server/instance/manager.go b/apps/backend/internal/agentctl/server/instance/manager.go index 9859760445..53dc625b51 100644 --- a/apps/backend/internal/agentctl/server/instance/manager.go +++ b/apps/backend/internal/agentctl/server/instance/manager.go @@ -89,6 +89,7 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea AgentType: req.AgentType, McpServers: mcpServers, SessionID: req.SessionID, + TaskID: req.TaskID, DisableAskQuestion: req.DisableAskQuestion, AssumeMcpSse: req.AssumeMcpSse, McpMode: req.McpMode, diff --git a/apps/backend/internal/agentctl/server/mcp/config_handlers_test.go b/apps/backend/internal/agentctl/server/mcp/config_handlers_test.go index 27279f0437..0444ee2425 100644 --- a/apps/backend/internal/agentctl/server/mcp/config_handlers_test.go +++ b/apps/backend/internal/agentctl/server/mcp/config_handlers_test.go @@ -36,7 +36,7 @@ func (tb *testBackend) RequestPayload(_ context.Context, action string, payload, func newTestServer(t *testing.T, backend BackendClient) *Server { t.Helper() log := newTestLogger(t) - return New(backend, "test-session", 10005, log, "", false, ModeConfig) + return New(backend, "test-session", "", 10005, log, "", false, ModeConfig) } func callTool(t *testing.T, s *Server, toolName string, args map[string]interface{}) *mcplib.CallToolResult { diff --git a/apps/backend/internal/agentctl/server/mcp/handlers.go b/apps/backend/internal/agentctl/server/mcp/handlers.go index 29eeb1d971..a0d6c7819a 100644 --- a/apps/backend/internal/agentctl/server/mcp/handlers.go +++ b/apps/backend/internal/agentctl/server/mcp/handlers.go @@ -272,9 +272,18 @@ func (s *Server) notifyClarificationTimeout() { } } +// resolveTaskID returns the server-injected taskID if available, otherwise falls back +// to the agent-provided value. This prevents LLM hallucination of task IDs. +func (s *Server) resolveTaskID(req mcp.CallToolRequest) (string, error) { + if s.taskID != "" { + return s.taskID, nil + } + return req.RequireString("task_id") +} + func (s *Server) createTaskPlanHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - taskID, err := req.RequireString("task_id") + taskID, err := s.resolveTaskID(req) if err != nil { return mcp.NewToolResultError("task_id is required"), nil } @@ -301,7 +310,7 @@ func (s *Server) createTaskPlanHandler() server.ToolHandlerFunc { func (s *Server) getTaskPlanHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - taskID, err := req.RequireString("task_id") + taskID, err := s.resolveTaskID(req) if err != nil { return mcp.NewToolResultError("task_id is required"), nil } @@ -329,7 +338,7 @@ func (s *Server) getTaskPlanHandler() server.ToolHandlerFunc { func (s *Server) updateTaskPlanHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - taskID, err := req.RequireString("task_id") + taskID, err := s.resolveTaskID(req) if err != nil { return mcp.NewToolResultError("task_id is required"), nil } @@ -359,7 +368,7 @@ func (s *Server) updateTaskPlanHandler() server.ToolHandlerFunc { func (s *Server) deleteTaskPlanHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - taskID, err := req.RequireString("task_id") + taskID, err := s.resolveTaskID(req) if err != nil { return mcp.NewToolResultError("task_id is required"), nil } diff --git a/apps/backend/internal/agentctl/server/mcp/server.go b/apps/backend/internal/agentctl/server/mcp/server.go index d788fd56a2..e0976315df 100644 --- a/apps/backend/internal/agentctl/server/mcp/server.go +++ b/apps/backend/internal/agentctl/server/mcp/server.go @@ -44,6 +44,7 @@ func normalizeMode(mode string) string { type Server struct { backend BackendClient sessionID string + taskID string disableAskQuestion bool mode string // "task" (default) or "config" mcpServer *server.MCPServer @@ -58,11 +59,12 @@ type Server struct { // New creates a new MCP server for agentctl. // port is the HTTP server port used to build the SSE base URL (http://localhost:). // mcpLogFile is an optional file path for MCP debug logging; pass "" to disable. -func New(backend BackendClient, sessionID string, port int, log *logger.Logger, mcpLogFile string, disableAskQuestion bool, mcpMode string) *Server { +func New(backend BackendClient, sessionID, taskID string, port int, log *logger.Logger, mcpLogFile string, disableAskQuestion bool, mcpMode string) *Server { mcpMode = normalizeMode(mcpMode) s := &Server{ backend: backend, sessionID: sessionID, + taskID: taskID, disableAskQuestion: disableAskQuestion, mode: mcpMode, logger: log.WithFields(zap.String("component", "mcp-server")), diff --git a/apps/backend/internal/agentctl/server/mcp/server_test.go b/apps/backend/internal/agentctl/server/mcp/server_test.go index df866dd818..6b6356dd9f 100644 --- a/apps/backend/internal/agentctl/server/mcp/server_test.go +++ b/apps/backend/internal/agentctl/server/mcp/server_test.go @@ -30,7 +30,7 @@ func TestServerModeTask_RegistersCorrectTools(t *testing.T) { backend := NewChannelBackendClient(log) defer backend.Close() - s := New(backend, "test-session", 10005, log, "", false, ModeTask) + s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask) require.NotNil(t, s) tools := getRegisteredToolNames(s) @@ -84,7 +84,7 @@ func TestServerModeConfig_RegistersCorrectTools(t *testing.T) { backend := NewChannelBackendClient(log) defer backend.Close() - s := New(backend, "test-session", 10005, log, "", false, ModeConfig) + s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeConfig) require.NotNil(t, s) tools := getRegisteredToolNames(s) @@ -146,7 +146,7 @@ func TestServerModeDefault_DefaultsToTask(t *testing.T) { backend := NewChannelBackendClient(log) defer backend.Close() - s := New(backend, "test-session", 10005, log, "", false, "") + s := New(backend, "test-session", "test-task", 10005, log, "", false, "") require.NotNil(t, s) assert.Equal(t, ModeTask, s.mode) @@ -161,7 +161,7 @@ func TestServerModeConfig_DisableAskQuestion(t *testing.T) { backend := NewChannelBackendClient(log) defer backend.Close() - s := New(backend, "test-session", 10005, log, "", true, ModeConfig) + s := New(backend, "test-session", "test-task", 10005, log, "", true, ModeConfig) require.NotNil(t, s) tools := getRegisteredToolNames(s) @@ -175,7 +175,7 @@ func TestServerModeTask_DisableAskQuestion(t *testing.T) { backend := NewChannelBackendClient(log) defer backend.Close() - s := New(backend, "test-session", 10005, log, "", true, ModeTask) + s := New(backend, "test-session", "test-task", 10005, log, "", true, ModeTask) require.NotNil(t, s) tools := getRegisteredToolNames(s) @@ -189,7 +189,7 @@ func TestServerModeTask_ToolCount(t *testing.T) { backend := NewChannelBackendClient(log) defer backend.Close() - s := New(backend, "test-session", 10005, log, "", false, ModeTask) + s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask) tools := getRegisteredToolNames(s) // 6 kanban + 1 interaction + 4 plan = 11 assert.Equal(t, 11, len(tools)) @@ -200,7 +200,7 @@ func TestServerModeConfig_ToolCount(t *testing.T) { backend := NewChannelBackendClient(log) defer backend.Close() - s := New(backend, "test-session", 10005, log, "", false, ModeConfig) + s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeConfig) tools := getRegisteredToolNames(s) // 10 workflow + 4 agent + 4 mcp + 5 executor + 5 task + 1 interaction = 29 assert.Equal(t, 29, len(tools)) @@ -211,7 +211,7 @@ func TestServerModeConfig_ToolDescriptions(t *testing.T) { backend := NewChannelBackendClient(log) defer backend.Close() - s := New(backend, "test-session", 10005, log, "", false, ModeConfig) + s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeConfig) toolsMap := s.mcpServer.ListTools() diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index ad4630cc1b..f2e12e054c 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -70,6 +70,7 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.TurnStarted, ws.ActionSessionTurnStarted) b.subscribe(eventBus, events.TurnCompleted, ws.ActionSessionTurnCompleted) b.subscribe(eventBus, events.MessageQueueStatusChanged, ws.ActionMessageQueueStatusChanged) + b.subscribe(eventBus, events.GitHubTaskPRUpdated, ws.ActionGitHubTaskPRUpdated) go func() { <-ctx.Done() diff --git a/apps/backend/internal/github/client_helpers.go b/apps/backend/internal/github/client_helpers.go index e6ff095c7a..2845b8cb1d 100644 --- a/apps/backend/internal/github/client_helpers.go +++ b/apps/backend/internal/github/client_helpers.go @@ -195,21 +195,30 @@ func convertRawStatusContexts(raw []ghStatusContext) []CheckRun { return checks } -// mergeChecks deduplicates check runs and commit statuses by normalized name/url. -// If both sources refer to the same item, check-run data wins. +// mergeChecks deduplicates check runs and commit statuses by normalized name. +// Check-run source wins over status-context; among same-source duplicates the +// most recent entry (by StartedAt) is kept. func mergeChecks(checkRuns, statusChecks []CheckRun) []CheckRun { merged := make([]CheckRun, 0, len(checkRuns)+len(statusChecks)) byKey := make(map[string]int) for _, check := range statusChecks { key := checkMergeKey(check) + if idx, ok := byKey[key]; ok { + if isNewerCheck(check, merged[idx]) { + merged[idx] = check + } + continue + } byKey[key] = len(merged) merged = append(merged, check) } for _, check := range checkRuns { key := checkMergeKey(check) if idx, ok := byKey[key]; ok { - merged[idx] = check + if merged[idx].Source == checkSourceStatusContext || isNewerCheck(check, merged[idx]) { + merged[idx] = check + } continue } byKey[key] = len(merged) @@ -219,9 +228,18 @@ func mergeChecks(checkRuns, statusChecks []CheckRun) []CheckRun { } func checkMergeKey(check CheckRun) string { - name := strings.ToLower(strings.TrimSpace(check.Name)) - url := strings.ToLower(strings.TrimSpace(check.HTMLURL)) - return name + "|" + url + return strings.ToLower(strings.TrimSpace(check.Name)) +} + +// isNewerCheck reports whether a started more recently than b. +func isNewerCheck(a, b CheckRun) bool { + if a.StartedAt == nil { + return false + } + if b.StartedAt == nil { + return true + } + return a.StartedAt.After(*b.StartedAt) } // convertSearchItemToPR converts common search result fields into a PR struct. diff --git a/apps/backend/internal/github/client_helpers_test.go b/apps/backend/internal/github/client_helpers_test.go index fda9e25a73..0578f68796 100644 --- a/apps/backend/internal/github/client_helpers_test.go +++ b/apps/backend/internal/github/client_helpers_test.go @@ -264,6 +264,60 @@ func TestConvertRawStatusContextsAndMergeChecks(t *testing.T) { } } +func TestMergeChecksDuplicateCheckRunsByName(t *testing.T) { + t1 := time.Date(2025, 6, 1, 10, 0, 0, 0, time.UTC) + t2 := time.Date(2025, 6, 1, 11, 0, 0, 0, time.UTC) + + // Two check-runs with the same name but different URLs (different check suites). + checkRuns := []CheckRun{ + {Name: "Analyze (go)", Source: checkSourceCheckRun, Status: "in_progress", HTMLURL: "https://github.com/o/r/runs/1", StartedAt: &t1}, + {Name: "Analyze (go)", Source: checkSourceCheckRun, Status: "in_progress", HTMLURL: "https://github.com/o/r/runs/2", StartedAt: &t2}, + } + merged := mergeChecks(checkRuns, nil) + if len(merged) != 1 { + t.Fatalf("expected 1 check after dedupe, got %d", len(merged)) + } + if merged[0].HTMLURL != "https://github.com/o/r/runs/2" { + t.Errorf("expected newer check-run to win, got URL %s", merged[0].HTMLURL) + } +} + +func TestMergeChecksCheckRunWinsOverStatusDifferentURL(t *testing.T) { + t1 := time.Date(2025, 6, 1, 10, 0, 0, 0, time.UTC) + + checkRuns := []CheckRun{ + {Name: "ci/test", Source: checkSourceCheckRun, Status: "completed", Conclusion: "success", HTMLURL: "https://github.com/o/r/runs/1", StartedAt: &t1}, + } + statuses := []CheckRun{ + {Name: "ci/test", Source: checkSourceStatusContext, Status: "in_progress", HTMLURL: "https://other-url.com/status/1", StartedAt: &t1}, + } + merged := mergeChecks(checkRuns, statuses) + if len(merged) != 1 { + t.Fatalf("expected 1 check after dedupe, got %d", len(merged)) + } + if merged[0].Source != checkSourceCheckRun { + t.Errorf("expected check_run to win over status_context, got source %s", merged[0].Source) + } +} + +func TestIsNewerCheck(t *testing.T) { + t1 := time.Date(2025, 6, 1, 10, 0, 0, 0, time.UTC) + t2 := time.Date(2025, 6, 1, 11, 0, 0, 0, time.UTC) + + if isNewerCheck(CheckRun{StartedAt: &t1}, CheckRun{StartedAt: &t2}) { + t.Error("older should not be newer") + } + if !isNewerCheck(CheckRun{StartedAt: &t2}, CheckRun{StartedAt: &t1}) { + t.Error("newer should be newer") + } + if isNewerCheck(CheckRun{StartedAt: nil}, CheckRun{StartedAt: &t1}) { + t.Error("nil StartedAt should not be newer") + } + if !isNewerCheck(CheckRun{StartedAt: &t1}, CheckRun{StartedAt: nil}) { + t.Error("non-nil should be newer than nil") + } +} + func TestConvertSearchItemToPR(t *testing.T) { now := time.Now() pr := convertSearchItemToPR( diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 0d57774681..4f15238a98 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -346,6 +346,23 @@ animation: shimmer-vertical 1.5s ease-in-out infinite; } +@keyframes changes-tab-flash { + 0% { + background-color: transparent; + } + 25% { + background-color: color-mix(in oklab, var(--primary) 25%, transparent); + } + 100% { + background-color: transparent; + } +} + +.animate-changes-flash { + animation: changes-tab-flash 1s ease-out; + border-radius: 4px; +} + @keyframes chat-input-running { 0%, 100% { @@ -407,6 +424,7 @@ } @media (prefers-reduced-motion: reduce) { + .animate-changes-flash, .chat-input-running, .chat-input-running-plan, .chat-input-starting { diff --git a/apps/web/components/diff/diff-viewer-resolver.tsx b/apps/web/components/diff/diff-viewer-resolver.tsx index 8c29b18fc9..b7b527415f 100644 --- a/apps/web/components/diff/diff-viewer-resolver.tsx +++ b/apps/web/components/diff/diff-viewer-resolver.tsx @@ -33,14 +33,26 @@ interface DiffViewerResolverProps { enableExpansion?: boolean; /** Base git ref for fetching old content (pierre-diffs only) */ baseRef?: string; + /** Controlled expand-unchanged state (pierre-diffs only) */ + expandUnchanged?: boolean; + /** Callback when expand-unchanged is toggled (pierre-diffs only) */ + onToggleExpandUnchanged?: () => void; } export const DiffViewerResolved = memo(function DiffViewerResolved(props: DiffViewerResolverProps) { const provider = useEditorProvider("diff-viewer"); if (provider === "monaco") { // Strip pierre-diffs-only props - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { enableComments, baseRef, enableExpansion, ...rest } = props; + /* eslint-disable @typescript-eslint/no-unused-vars */ + const { + enableComments, + baseRef, + enableExpansion, + expandUnchanged, + onToggleExpandUnchanged, + ...rest + } = props; + /* eslint-enable @typescript-eslint/no-unused-vars */ return ; } return ; diff --git a/apps/web/components/diff/diff-viewer.tsx b/apps/web/components/diff/diff-viewer.tsx index e1f216a194..8e40ece5ab 100644 --- a/apps/web/components/diff/diff-viewer.tsx +++ b/apps/web/components/diff/diff-viewer.tsx @@ -38,6 +38,10 @@ interface DiffViewerProps { enableExpansion?: boolean; /** Base git ref for fetching old content (e.g., "origin/main", "HEAD~1") */ baseRef?: string; + /** Controlled expand-unchanged state (when provided, component is controlled) */ + expandUnchanged?: boolean; + /** Callback when expand-unchanged is toggled (controlled mode) */ + onToggleExpandUnchanged?: () => void; } const SCALAR_PROP_KEYS: (keyof DiffViewerProps)[] = [ @@ -53,6 +57,8 @@ const SCALAR_PROP_KEYS: (keyof DiffViewerProps)[] = [ "wordWrap", "enableExpansion", "baseRef", + "expandUnchanged", + "onToggleExpandUnchanged", ]; const DATA_KEYS: (keyof FileDiffData)[] = ["filePath", "diff", "oldContent", "newContent"]; @@ -100,6 +106,14 @@ function arePropsEqual(prevProps: DiffViewerProps, nextProps: DiffViewerProps): return areCommentsEqual(prevProps.comments, nextProps.comments); } +function NoDiffPlaceholder({ className }: { className?: string }) { + return ( +
+ No diff available +
+ ); +} + export const DiffViewer = memo(function DiffViewer({ data, enableComments = false, @@ -118,11 +132,17 @@ export const DiffViewer = memo(function DiffViewer({ wordWrap: wordWrapProp, enableExpansion = false, baseRef, + expandUnchanged: expandUnchangedProp, + onToggleExpandUnchanged: onToggleExpandUnchangedProp, }: DiffViewerProps) { const [wordWrapLocal, setWordWrap] = useState(false); const wordWrap = wordWrapProp ?? wordWrapLocal; - const [expandUnchanged, setExpandUnchanged] = useState(false); - const toggleExpandUnchanged = useCallback(() => setExpandUnchanged((v) => !v), []); + const [expandUnchangedLocal, setExpandUnchangedLocal] = useState(false); + const expandUnchanged = expandUnchangedProp ?? expandUnchangedLocal; + const toggleExpandUnchanged = useCallback(() => { + if (onToggleExpandUnchangedProp) onToggleExpandUnchangedProp(); + else setExpandUnchangedLocal((v) => !v); + }, [onToggleExpandUnchangedProp]); const wrapperRef = useRef(null); const state = useDiffViewerState({ @@ -182,13 +202,7 @@ export const DiffViewer = memo(function DiffViewer({ const controlledSelection = state.showCommentForm ? state.selectedLines : null; if (!state.fileDiffMetadata) { - return ( -
- No diff available -
- ); + return ; } return ( diff --git a/apps/web/components/diff/file-diff-viewer.tsx b/apps/web/components/diff/file-diff-viewer.tsx index 2b592f780b..d10299cc54 100644 --- a/apps/web/components/diff/file-diff-viewer.tsx +++ b/apps/web/components/diff/file-diff-viewer.tsx @@ -28,6 +28,10 @@ interface FileDiffViewerProps { enableExpansion?: boolean; /** Base git ref for fetching old content (e.g., "origin/main", "HEAD~1") */ baseRef?: string; + /** Controlled expand-unchanged state */ + expandUnchanged?: boolean; + /** Callback when expand-unchanged is toggled (controlled mode) */ + onToggleExpandUnchanged?: () => void; } /** @@ -59,6 +63,8 @@ export const FileDiffViewer = memo(function FileDiffViewer({ wordWrap, enableExpansion, baseRef, + expandUnchanged, + onToggleExpandUnchanged, }: FileDiffViewerProps) { const data = useMemo(() => transformGitDiff(filePath, diff, status), [filePath, diff, status]); @@ -81,6 +87,8 @@ export const FileDiffViewer = memo(function FileDiffViewer({ wordWrap={wordWrap} enableExpansion={enableExpansion} baseRef={baseRef} + expandUnchanged={expandUnchanged} + onToggleExpandUnchanged={onToggleExpandUnchanged} /> ); }); diff --git a/apps/web/components/diff/use-diff-comments.ts b/apps/web/components/diff/use-diff-comments.ts index 8e5509407e..22bb656d71 100644 --- a/apps/web/components/diff/use-diff-comments.ts +++ b/apps/web/components/diff/use-diff-comments.ts @@ -25,7 +25,7 @@ interface UseDiffCommentsReturn { /** Annotations formatted for @pierre/diffs */ annotations: CommentAnnotation[]; /** Add a new comment */ - addComment: (range: SelectedLineRange, text: string) => void; + addComment: (range: SelectedLineRange, text: string) => DiffComment; /** Remove a comment */ removeComment: (commentId: string) => void; /** Update a comment */ @@ -91,6 +91,7 @@ export function useDiffComments({ description: "Your comment will be sent with your next message.", duration: 2000, }); + return comment; }, [sessionId, filePath, diff, newContent, oldContent, storeAddComment], ); diff --git a/apps/web/components/diff/use-diff-viewer-state.ts b/apps/web/components/diff/use-diff-viewer-state.ts index ee694f3b57..a66b7c5e89 100644 --- a/apps/web/components/diff/use-diff-viewer-state.ts +++ b/apps/web/components/diff/use-diff-viewer-state.ts @@ -189,7 +189,7 @@ type CommentHandlerOpts = { externalComments?: DiffComment[]; data: FileDiffData; sessionId?: string; - addComment: (range: SelectedLineRange, content: string) => void; + addComment: (range: SelectedLineRange, content: string) => DiffComment; removeComment: (commentId: string) => void; updateComment: (commentId: string, updates: Partial) => void; setEditingComment: (commentId: string | null) => void; @@ -246,11 +246,8 @@ function useDiffViewerCommentHandlers(opts: CommentHandlerOpts) { runAfter?.(comment); } } else if (sessionId) { - addComment(selectedLines, content); - if (runAfter) { - const comment = createCommentFromSelection(content); - if (comment) runAfter(comment); - } + const stored = addComment(selectedLines, content); + if (runAfter && stored) runAfter(stored); } setShowCommentForm(false); setSelectedLines(null); diff --git a/apps/web/components/github/pr-detail-panel.tsx b/apps/web/components/github/pr-detail-panel.tsx index e2f37fc8da..f0762020f7 100644 --- a/apps/web/components/github/pr-detail-panel.tsx +++ b/apps/web/components/github/pr-detail-panel.tsx @@ -299,6 +299,7 @@ function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; sessionId: str function StateBadge({ state }: { state: string }) { const styles: Record = { open: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", + draft: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400", merged: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400", closed: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", }; @@ -421,7 +422,7 @@ function PRHeader({ const liveState = feedback?.pr.state ?? taskPR.state; const isDraft = feedback?.pr.draft ?? false; const isMergeable = feedback?.pr.mergeable ?? true; - const showWarnings = isDraft || (!isMergeable && liveState === "open"); + const showWarnings = !isDraft && !isMergeable && liveState === "open"; return (
@@ -432,7 +433,7 @@ function PRHeader({
- + #{taskPR.pr_number} {taskPR.head_branch} @@ -444,20 +445,10 @@ function PRHeader({
{showWarnings && (
- {isDraft && ( - - Draft - - )} - {!isMergeable && liveState === "open" && ( - - - Not mergeable - - )} + + + Not mergeable +
)} diff --git a/apps/web/components/review/review-diff-list.tsx b/apps/web/components/review/review-diff-list.tsx index 1e55a4ea2a..fc6c559ae0 100644 --- a/apps/web/components/review/review-diff-list.tsx +++ b/apps/web/components/review/review-diff-list.tsx @@ -6,7 +6,13 @@ import { IconArrowBackUp, IconChevronDown, IconChevronRight, + IconCopy, + IconFold, + IconFoldDown, + IconLayoutColumns, + IconLayoutRows, IconPencil, + IconTextWrap, } from "@tabler/icons-react"; import { Checkbox } from "@kandev/ui/checkbox"; import { Button } from "@kandev/ui/button"; @@ -17,6 +23,7 @@ import { FileActionsDropdown } from "@/components/editors/file-actions-dropdown" import { getWebSocketClient } from "@/lib/ws/connection"; import { requestFileContent, updateFileContent } from "@/lib/ws/workspace-files"; import { generateUnifiedDiff, calculateHash } from "@/lib/utils/file-diff"; +import { useGlobalViewMode } from "@/hooks/use-global-view-mode"; import { useAppStore } from "@/components/state-provider"; import { useRunComment } from "@/hooks/domains/comments/use-run-comment"; import type { DiffComment } from "@/lib/diff/types"; @@ -160,28 +167,154 @@ function useAutoMarkOnScroll({ return scrollSentinelRef; } +const iconBtn = "h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"; +const iconBtnActive = "h-6 w-6 p-0 cursor-pointer opacity-100 bg-muted"; + +type FileDiffToolbarProps = { + diff: string; + filePath: string; + sessionId: string; + source: string; + wordWrap: boolean; + expandUnchanged: boolean; + onDiscard: () => void; + onOpenFile?: (filePath: string) => void; + onToggleExpandUnchanged: () => void; + onToggleWordWrap: () => void; +}; + +function ToolbarIconBtn({ + onClick, + tooltip, + active, + children, + className, +}: { + onClick: () => void; + tooltip: string; + active?: boolean; + children: React.ReactNode; + className?: string; +}) { + return ( + + + + + {tooltip} + + ); +} + +function FileDiffToolbar(props: FileDiffToolbarProps) { + const { + diff, + filePath, + sessionId, + source, + wordWrap, + expandUnchanged, + onDiscard, + onOpenFile, + onToggleExpandUnchanged, + onToggleWordWrap, + } = props; + const [globalViewMode, setGlobalViewMode] = useGlobalViewMode(); + const handleCopyDiff = useCallback(() => { + navigator.clipboard.writeText(diff || ""); + }, [diff]); + const handleToggleViewMode = useCallback( + () => setGlobalViewMode(globalViewMode === "split" ? "unified" : "split"), + [globalViewMode, setGlobalViewMode], + ); + return ( +
+ + + + + {expandUnchanged ? ( + + ) : ( + + )} + + + + + + {globalViewMode === "split" ? ( + + ) : ( + + )} + + {onOpenFile && ( + onOpenFile(filePath)} tooltip="Edit"> + + + )} + + {source === "uncommitted" && ( + + + + )} +
+ ); +} + type FileDiffHeaderProps = { file: ReviewFile; isReviewed: boolean; isStale: boolean; sessionId: string; collapsed: boolean; + wordWrap: boolean; + expandUnchanged: boolean; onCheckboxChange: (checked: boolean | "indeterminate") => void; onDiscard: () => void; onOpenFile?: (filePath: string) => void; onToggleCollapse: () => void; + onToggleExpandUnchanged: () => void; + onToggleWordWrap: () => void; }; function FileDiffHeader({ file, isReviewed, isStale, - sessionId, collapsed, + wordWrap, + expandUnchanged, + sessionId, onCheckboxChange, onDiscard, onOpenFile, onToggleCollapse, + onToggleExpandUnchanged, + onToggleWordWrap, }: FileDiffHeaderProps) { return (
@@ -212,39 +345,18 @@ function FileDiffHeader({ {file.additions > 0 && file.deletions > 0 && " / "} {file.deletions > 0 && -{file.deletions}} -
- {onOpenFile && ( - - - - - Edit - - )} - - {file.source === "uncommitted" && ( - - - - - Revert changes - - )} -
+
); } @@ -288,6 +400,23 @@ async function revertBlock(sessionId: string, filePath: string, info: RevertBloc } } +function useScrollIntoViewOnSelect( + isSelected: boolean | undefined, + sectionRef: React.RefObject | undefined, + setCollapsed: React.Dispatch>, +) { + useEffect(() => { + if (isSelected) { + setCollapsed(false); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + sectionRef?.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + }); + } + }, [isSelected, sectionRef, setCollapsed]); +} + function FileDiffSection({ file, isReviewed, @@ -304,23 +433,19 @@ function FileDiffSection({ scrollContainer, }: FileDiffSectionProps) { const [collapsed, setCollapsed] = useState(false); + const [expandUnchanged, setExpandUnchanged] = useState(false); + const [localWordWrap, setLocalWordWrap] = useState(undefined); + const effectiveWordWrap = localWordWrap ?? wordWrap; const handleToggleCollapse = useCallback(() => setCollapsed((v) => !v), []); + const handleToggleExpandUnchanged = useCallback(() => setExpandUnchanged((v) => !v), []); + const handleToggleWordWrap = useCallback( + () => setLocalWordWrap((v) => !(v ?? wordWrap)), + [wordWrap], + ); const { isVisible, sentinelRef } = useLazyVisible(scrollContainer); // Force load when: visible via intersection observer, or forceLoad is true (all files up to selected) const shouldRenderContent = isVisible || forceLoad; - useEffect(() => { - if (isSelected) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing collapsed state from parent selection prop - setCollapsed(false); - // Wait for all diffs above to fully render before scrolling - // Double rAF ensures layout is complete after React commit - requestAnimationFrame(() => { - requestAnimationFrame(() => { - sectionRef?.current?.scrollIntoView({ behavior: "smooth", block: "start" }); - }); - }); - } - }, [isSelected, sectionRef]); + useScrollIntoViewOnSelect(isSelected, sectionRef, setCollapsed); const scrollSentinelRef = useAutoMarkOnScroll({ autoMarkOnScroll, isReviewed, @@ -355,29 +480,34 @@ function FileDiffSection({ isStale={isStale} sessionId={sessionId} collapsed={collapsed} + wordWrap={effectiveWordWrap} + expandUnchanged={expandUnchanged} onCheckboxChange={handleCheckboxChange} onDiscard={handleDiscard} onOpenFile={onOpenFile} onToggleCollapse={handleToggleCollapse} + onToggleExpandUnchanged={handleToggleExpandUnchanged} + onToggleWordWrap={handleToggleWordWrap} />
{!collapsed && (shouldRenderContent && file.diff ? ( -
- -
+ ) : (
Loading diff... diff --git a/apps/web/components/task/changes-panel.tsx b/apps/web/components/task/changes-panel.tsx index 59b6b678fe..f1e69a0201 100644 --- a/apps/web/components/task/changes-panel.tsx +++ b/apps/web/components/task/changes-panel.tsx @@ -402,15 +402,18 @@ function ChangesPanelBody(props: ChangesPanelBodyProps) { function useChangesPanelPRData() { const taskPR = useActiveTaskPR(); + const refreshKey = taskPR?.last_synced_at ?? null; const { files: prDiffFiles } = usePRDiff( taskPR?.owner ?? null, taskPR?.repo ?? null, taskPR?.pr_number ?? null, + refreshKey, ); const { commits: prCommitsList } = usePRCommits( taskPR?.owner ?? null, taskPR?.repo ?? null, taskPR?.pr_number ?? null, + refreshKey, ); const hasPRFiles = prDiffFiles.length > 0; const hasPRCommits = prCommitsList.length > 0; diff --git a/apps/web/components/task/changes-tab.tsx b/apps/web/components/task/changes-tab.tsx new file mode 100644 index 0000000000..89b424ef85 --- /dev/null +++ b/apps/web/components/task/changes-tab.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { DockviewDefaultTab, type IDockviewPanelHeaderProps } from "dockview-react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@kandev/ui/context-menu"; +import { useAppStore } from "@/components/state-provider"; +import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; +import { useSessionCommits } from "@/hooks/domains/session/use-session-commits"; +import { useDockviewStore } from "@/lib/state/dockview-store"; +import { focusOrAddPanel } from "@/lib/state/dockview-layout-builders"; +import { cn } from "@kandev/ui/lib/utils"; + +/** Auto-activate the changes panel in the right sidebar, or quietly add to center if missing. */ +function autoActivateChangesPanel(): void { + const { api, rightTopGroupId, centerGroupId } = useDockviewStore.getState(); + if (!api) return; + + const panel = api.getPanel("changes"); + if (panel && panel.group.id === rightTopGroupId) { + panel.api.setActive(); + return; + } + + if (!panel) { + focusOrAddPanel( + api, + { + id: "changes", + component: "changes", + tabComponent: "changesTab", + title: "Changes", + position: { referenceGroup: centerGroupId }, + }, + true, + ); + } +} + +/** + * Custom tab component for the Changes panel. + * Provides auto-activation, flash animation on new changes, + * and a badge showing unseen change count. + */ +export function ChangesTab(props: IDockviewPanelHeaderProps) { + const { api, containerApi } = props; + + const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); + const gitStatus = useSessionGitStatus(activeSessionId); + const { commits } = useSessionCommits(activeSessionId ?? null); + const fileCount = gitStatus?.files ? Object.keys(gitStatus.files).length : 0; + const totalCount = fileCount + commits.length; + + const prevTotalRef = useRef(totalCount); + const seenCountRef = useRef(api.isActive ? totalCount : 0); + const flashTimerRef = useRef | null>(null); + + const [isFlashing, setIsFlashing] = useState(false); + const [badgeCount, setBadgeCount] = useState(0); + + // Reset seenCount when the user activates this tab + useEffect(() => { + const disposable = api.onDidActiveChange((event) => { + if (event.isActive) { + seenCountRef.current = totalCount; + setBadgeCount(0); + } + }); + return () => disposable.dispose(); + }, [api, totalCount]); + + // React to totalCount changes: auto-activate, flash, badge + useEffect(() => { + if (api.isActive) { + seenCountRef.current = totalCount; + } + + const prev = prevTotalRef.current; + prevTotalRef.current = totalCount; + + const increased = totalCount > prev && totalCount > 0; + const decreased = totalCount < prev; + + if (increased && prev === 0) { + autoActivateChangesPanel(); + } + + if (increased) { + if (flashTimerRef.current) clearTimeout(flashTimerRef.current); + // Defer setState to satisfy react-hooks/set-state-in-effect + flashTimerRef.current = setTimeout(() => setIsFlashing(false), 1000); + requestAnimationFrame(() => setIsFlashing(true)); + } + + if ((increased || decreased) && !api.isActive) { + const unseen = Math.max(0, totalCount - seenCountRef.current); + requestAnimationFrame(() => setBadgeCount(unseen)); + } + }, [totalCount, api]); + + // Cleanup flash timer on unmount + useEffect(() => { + return () => { + if (flashTimerRef.current) clearTimeout(flashTimerRef.current); + }; + }, []); + + const handleCloseOthers = useCallback(() => { + const toClose = api.group.panels.filter((p) => p.id !== api.id && p.id !== "chat"); + for (const panel of toClose) containerApi.removePanel(panel); + }, [api, containerApi]); + + return ( + + +
+ + {badgeCount > 0 && ( + + )} +
+
+ + + Close Others + + +
+ ); +} diff --git a/apps/web/components/task/chat/messages/rich-blocks.tsx b/apps/web/components/task/chat/messages/rich-blocks.tsx index 05af19e233..e4ef3aab18 100644 --- a/apps/web/components/task/chat/messages/rich-blocks.tsx +++ b/apps/web/components/task/chat/messages/rich-blocks.tsx @@ -91,11 +91,7 @@ export function RichBlocks({ comment }: { comment: Message }) {
{metadata.thinking}
)} - {hasTodos && ( -
- -
- )} + {hasTodos && } {diffData && } {!diffData && diffText && (
diff --git a/apps/web/components/task/chat/messages/todo-message.tsx b/apps/web/components/task/chat/messages/todo-message.tsx index 62067cc57f..0688757a9c 100644 --- a/apps/web/components/task/chat/messages/todo-message.tsx +++ b/apps/web/components/task/chat/messages/todo-message.tsx @@ -35,39 +35,44 @@ export function TodoMessage({ if (!todoItems.length) return null; const completed = todoItems.filter((t) => resolveStatus(t) === "completed").length; + const currentTask = todoItems.find((t) => resolveStatus(t) === "in_progress"); return ( -
- } - header={ -
- - Updated Todos - - - {completed}/{todoItems.length} - -
- } - hasExpandableContent={todoItems.length > 0} - isExpanded={isExpanded} - onToggle={() => setIsExpanded((prev) => !prev)} - > -
- {todoItems.map((todo, idx) => { - const s = resolveStatus(todo); - return ( -
- - - {todo.text} - -
- ); - })} + } + header={ +
+ + Updated Todos + + + ({completed}/{todoItems.length}) + + {currentTask && ( + <> + ยท + {currentTask.text} + + )}
-
-
+ } + hasExpandableContent={todoItems.length > 0} + isExpanded={isExpanded} + onToggle={() => setIsExpanded((prev) => !prev)} + > +
+ {todoItems.map((todo, idx) => { + const s = resolveStatus(todo); + return ( +
+ + + {todo.text} + +
+ ); + })} +
+
); } diff --git a/apps/web/components/task/chat/todo-indicator.tsx b/apps/web/components/task/chat/todo-indicator.tsx index 1d51d269c2..2cb090c40d 100644 --- a/apps/web/components/task/chat/todo-indicator.tsx +++ b/apps/web/components/task/chat/todo-indicator.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useRef } from "react"; import { IconCheck, IconCircle, IconCircleFilled, IconListCheck, IconX } from "@tabler/icons-react"; import { HoverCard, HoverCardTrigger, HoverCardContent } from "@kandev/ui/hover-card"; import { cn } from "@/lib/utils"; @@ -32,6 +33,33 @@ function StatusIcon({ status, className }: { status: string; className?: string } } +function TodoList({ todos }: { todos: TodoDisplayItem[] }) { + const listRef = useRef(null); + + useEffect(() => { + listRef.current?.scrollTo({ top: listRef.current.scrollHeight }); + }, []); + + return ( +
+ {todos.map((todo, idx) => { + const s = resolveStatus(todo); + return ( +
+ + {idx + 1} + + + + {todo.text} + +
+ ); + })} +
+ ); +} + export function TodoIndicator({ todos }: TodoIndicatorProps) { if (!todos.length) return null; @@ -70,19 +98,7 @@ export function TodoIndicator({ todos }: TodoIndicatorProps) { style={{ width: `${progress}%` }} />
-
- {todos.map((todo, idx) => { - const s = resolveStatus(todo); - return ( -
- - - {todo.text} - -
- ); - })} -
+ ); diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index 46f7b82850..f850389565 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -33,6 +33,7 @@ import { FileEditorPanel } from "./file-editor-panel"; import { PassthroughTerminal } from "./passthrough-terminal"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { ContextMenuTab } from "./tab-context-menu"; +import { ChangesTab } from "./changes-tab"; import { TerminalPanel } from "./terminal-panel"; import { BrowserPanel } from "./browser-panel"; import { VscodePanel } from "./vscode-panel"; @@ -145,6 +146,7 @@ function PermanentTab(props: IDockviewPanelHeaderProps) { const tabComponents: Record> = { permanentTab: PermanentTab, + changesTab: ChangesTab, }; // --------------------------------------------------------------------------- diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index c276b18c2d..7fff174440 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -1,8 +1,8 @@ "use client"; import { memo, useState } from "react"; -import { IconLoader2, IconGitMerge } from "@tabler/icons-react"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { IconLoader2 } from "@tabler/icons-react"; +import { PRTaskIcon } from "@/components/github/pr-task-icon"; import { cn } from "@/lib/utils"; import type { TaskState, TaskSessionState } from "@/lib/types/http"; import { TaskItemMenu } from "./task-item-menu"; @@ -35,7 +35,6 @@ type TaskItemProps = { isDeleting?: boolean; taskId?: string; primarySessionId?: string | null; - prState?: "open" | "closed" | "merged" | null; }; // Helper to format relative time @@ -64,26 +63,17 @@ function handleTaskItemKeyDown(e: React.KeyboardEvent, onClick?: function TaskItemTitle({ title, description, - prState, + taskId, }: { title: string; description?: string; - prState?: "open" | "closed" | "merged" | null; + taskId?: string; }) { return (
- {prState === "merged" && ( - - - - - - - PR merged - - )} {title} + {taskId && } {description && ( {description} @@ -162,7 +152,6 @@ export const TaskItem = memo(function TaskItem({ isDeleting, taskId, primarySessionId, - prState, }: TaskItemProps) { const [menuOpen, setMenuOpen] = useState(false); @@ -196,7 +185,7 @@ export const TaskItem = memo(function TaskItem({ /> {/* Content */} - + {/* Right side: step name + meta, or action buttons on hover */}
diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 58d5b76421..aff5b18db6 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -128,13 +128,20 @@ function useSidebarData(workspaceId: string | null) { const tasksWithRepositories = useMemo(() => { const repositories = workspaceId ? (repositoriesByWorkspace[workspaceId] ?? []) : []; - const repositoryPathsById = new Map( - repositories.map((repo: Repository) => [repo.id, repo.local_path]), + const repositorySlugById = new Map( + repositories.map((repo: Repository) => [ + repo.id, + repo.provider_owner && repo.provider_name + ? `${repo.provider_owner}/${repo.provider_name}` + : repo.local_path, + ]), ); const items = allTasks.map((task: KanbanState["tasks"][number]) => { const sessionInfo = getSessionInfoForTask(task.id, sessionsByTaskId, gitStatusBySessionId); const resolvedSessionState = sessionInfo.sessionState ?? (task.primarySessionState as TaskSessionState | undefined); + const repoSlug = task.repositoryId ? repositorySlugById.get(task.repositoryId) : undefined; + const pr = taskPRsByTaskId[task.id]; return { id: task.id, title: task.title, @@ -142,7 +149,7 @@ function useSidebarData(workspaceId: string | null) { sessionState: resolvedSessionState, description: task.description, workflowStepId: task.workflowStepId as string | undefined, - repositoryPath: task.repositoryId ? repositoryPathsById.get(task.repositoryId) : undefined, + repositoryPath: pr ? `${pr.owner}/${pr.repo}` : repoSlug, diffStats: sessionInfo.diffStats, isRemoteExecutor: task.isRemoteExecutor, remoteExecutorType: task.primaryExecutorType ?? undefined, @@ -150,7 +157,6 @@ function useSidebarData(workspaceId: string | null) { primarySessionId: task.primarySessionId ?? null, updatedAt: sessionInfo.updatedAt ?? task.updatedAt, isArchived: false as boolean, - prState: (taskPRsByTaskId[task.id]?.state as "open" | "closed" | "merged") ?? null, }; }); if ( @@ -173,9 +179,6 @@ function useSidebarData(workspaceId: string | null) { primarySessionId: null, updatedAt: archivedState.archivedTaskUpdatedAt, isArchived: true, - prState: - (taskPRsByTaskId[archivedState.archivedTaskId]?.state as "open" | "closed" | "merged") ?? - null, }); } return items; diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 800affca5c..3e79f3c52f 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -36,7 +36,6 @@ type TaskSwitcherItem = { updatedAt?: string; isArchived?: boolean; primarySessionId?: string | null; - prState?: "open" | "closed" | "merged" | null; }; type TaskSwitcherProps = { @@ -150,7 +149,6 @@ function TaskSwitcherSection({ taskId={task.id} primarySessionId={task.primarySessionId ?? null} updatedAt={task.updatedAt} - prState={task.prState} onClick={() => onSelectTask(task.id)} onRename={onRenameTask ? () => onRenameTask(task.id, task.title) : undefined} onArchive={onArchiveTask ? () => onArchiveTask(task.id) : undefined} diff --git a/apps/web/hooks/domains/comments/use-run-comment.ts b/apps/web/hooks/domains/comments/use-run-comment.ts index 26c84cafe7..6a34d6c447 100644 --- a/apps/web/hooks/domains/comments/use-run-comment.ts +++ b/apps/web/hooks/domains/comments/use-run-comment.ts @@ -86,7 +86,7 @@ export function useRunComment({ sessionId, taskId, isAgentBusy }: UseRunCommentP task_id: taskId, session_id: sessionId, content, - has_review_comments: true, + ...(comment.source !== "plan" && { has_review_comments: true }), }, 10000, ); diff --git a/apps/web/hooks/domains/github/use-pr-commits.ts b/apps/web/hooks/domains/github/use-pr-commits.ts index e996f381b7..e3b58019a4 100644 --- a/apps/web/hooks/domains/github/use-pr-commits.ts +++ b/apps/web/hooks/domains/github/use-pr-commits.ts @@ -46,7 +46,12 @@ async function fetchPRCommits( * Fetches the commits in a pull request via WebSocket. * Returns commit metadata from the GitHub API. */ -export function usePRCommits(owner: string | null, repo: string | null, prNumber: number | null) { +export function usePRCommits( + owner: string | null, + repo: string | null, + prNumber: number | null, + refreshKey?: string | null, +) { const [state, setState] = useState(INITIAL_STATE); const hasParams = !!owner && !!repo && !!prNumber; const paramsKeyRef = useRef(""); @@ -62,7 +67,7 @@ export function usePRCommits(owner: string | null, repo: string | null, prNumber }, [owner, repo, prNumber]); useEffect(() => { - const key = hasParams ? `${owner}/${repo}/${prNumber}` : ""; + const key = hasParams ? `${owner}/${repo}/${prNumber}/${refreshKey ?? ""}` : ""; if (key === paramsKeyRef.current) return; paramsKeyRef.current = key; if (!owner || !repo || !prNumber) { @@ -74,7 +79,7 @@ export function usePRCommits(owner: string | null, repo: string | null, prNumber if (requestId !== requestIdRef.current) return; setState(next); }); - }, [owner, repo, prNumber, hasParams]); + }, [owner, repo, prNumber, hasParams, refreshKey]); // Return initial state when params are null to clear stale data if (!hasParams) return { ...INITIAL_STATE, refresh }; diff --git a/apps/web/hooks/domains/github/use-pr-diff.ts b/apps/web/hooks/domains/github/use-pr-diff.ts index 33c223a246..f8314208ba 100644 --- a/apps/web/hooks/domains/github/use-pr-diff.ts +++ b/apps/web/hooks/domains/github/use-pr-diff.ts @@ -46,7 +46,12 @@ async function fetchPRFiles( * Fetches the files changed in a pull request via WebSocket. * Returns structured diff data from the GitHub API with full unified diff patches. */ -export function usePRDiff(owner: string | null, repo: string | null, prNumber: number | null) { +export function usePRDiff( + owner: string | null, + repo: string | null, + prNumber: number | null, + refreshKey?: string | null, +) { const [state, setState] = useState(INITIAL_STATE); const hasParams = !!owner && !!repo && !!prNumber; const paramsKeyRef = useRef(""); @@ -62,7 +67,7 @@ export function usePRDiff(owner: string | null, repo: string | null, prNumber: n }, [owner, repo, prNumber]); useEffect(() => { - const key = hasParams ? `${owner}/${repo}/${prNumber}` : ""; + const key = hasParams ? `${owner}/${repo}/${prNumber}/${refreshKey ?? ""}` : ""; if (key === paramsKeyRef.current) return; paramsKeyRef.current = key; if (!owner || !repo || !prNumber) { @@ -74,7 +79,7 @@ export function usePRDiff(owner: string | null, repo: string | null, prNumber: n if (requestId !== requestIdRef.current) return; setState(next); }); - }, [owner, repo, prNumber, hasParams]); + }, [owner, repo, prNumber, hasParams, refreshKey]); // Return initial state when params are null to clear stale data if (!hasParams) return { ...INITIAL_STATE, refresh }; diff --git a/apps/web/lib/state/dockview-panel-actions.ts b/apps/web/lib/state/dockview-panel-actions.ts index b7165ea039..a37d36bd16 100644 --- a/apps/web/lib/state/dockview-panel-actions.ts +++ b/apps/web/lib/state/dockview-panel-actions.ts @@ -16,6 +16,7 @@ type SimplePanelOpts = { id: string; component: string; title: string; + tabComponent?: string; params?: Record; }; @@ -45,6 +46,7 @@ export function buildPanelActions(set: StoreSet, get: StoreGet) { id: "changes", component: "changes", title: "Changes", + tabComponent: "changesTab", }); }, addFilesPanel: (groupId?: string) => { diff --git a/apps/web/lib/state/layout-manager/constants.ts b/apps/web/lib/state/layout-manager/constants.ts index f7d4f1231a..21e7112055 100644 --- a/apps/web/lib/state/layout-manager/constants.ts +++ b/apps/web/lib/state/layout-manager/constants.ts @@ -44,7 +44,7 @@ export const PANEL_REGISTRY: Record> = { sidebar: { component: "sidebar", title: "Sidebar" }, chat: { component: "chat", title: "Agent", tabComponent: "permanentTab" }, plan: { component: "plan", title: "Plan" }, - changes: { component: "changes", title: "Changes" }, + changes: { component: "changes", title: "Changes", tabComponent: "changesTab" }, files: { component: "files", title: "Files" }, browser: { component: "browser", title: "Browser", params: { url: "" } }, vscode: { component: "vscode", title: "VS Code" },